fix(playlists): what the first review of bulk actions found

An Esc during a pointer drag is spent on the drag and leaves the selection.
A wheel over the rail re-reads the target each frame, so a drop lands on the
row under the pointer. After a bulk removal the focus goes to the next row
left, and to the list's menu when the list is emptied.

remove-bulk names the videos that went, and its Undo brings back only those.
An Undo of a move keeps each row (and when it was saved), leaves out a video
that moved on from the target, closes up its place, and says how many came
back. Delete with every tick hidden removes nothing and says why. A list gone
before the lock answers 404, and a body without its videos 422.

One removal path now: the single row goes through the bulk one, the same
toast and undo, which also retires the add-at-position route. railDrop reuses
the store and scrollParent, the drag image the overlay and the z-index token,
and the sensors keep their number. The count is read out from a region that
outlives the band. Tests fill the gaps the review named, each seen failing.
This commit is contained in:
2026-11-02 03:41:08 +01:00
parent d09b5d6abb
commit 1a1e9d9542
24 changed files with 1168 additions and 379 deletions
+187 -158
View File
@@ -1,14 +1,20 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
lazy,
Suspense,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type MouseEvent,
} from "react";
import { useLatest, useStableCallback } from "../../lib/useLatest";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { hasResumePoint } from "../../lib/api/feed";
import {
addPlaylistItem,
movePlaylistItems,
removePlaylistItem,
removePlaylistItems,
restorePlaylistItems,
undoPlaylistMove,
@@ -21,6 +27,7 @@ import {
PointerSensor,
closestCenter,
DragOverlay,
KeyboardCode,
useSensor,
useSensors,
type CollisionDetection,
@@ -68,6 +75,7 @@ import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { useComfortPin, pinPlaylist, pinVideo } from "../../features/comfort/comfortPin";
import { ContextMenu } from "../../components/ui/ContextMenu";
import Overlay from "../../components/ui/Overlay";
import { useContextMenu } from "../../components/ui/useContextMenu";
import { usePageMenu } from "../../components/ui/pageMenuSlot";
import { menuGroups } from "../../components/ui/menuGroups";
@@ -136,6 +144,15 @@ const moveTargetIcon = (pl: Playlist) =>
<ListPlus className="w-4 h-4" />
);
/** Under a view a key would pick the row up with nowhere to put it: only the rail is left to go to.
* No key starts it, rather than no such sensor: dnd-kit takes the sensors as an effect's dependency
* list, whose size must not change. */
const NO_KEY_STARTS = {
start: [],
cancel: [KeyboardCode.Esc],
end: [KeyboardCode.Space, KeyboardCode.Enter, KeyboardCode.Tab],
};
/** A pointer drag lands where the pointer is. dnd-kit measures from the drag image, whose small
* box has its centre elsewhere: on a tablet's taller rows a drop reached the row before. */
function pointerFirst(args: Parameters<CollisionDetection>[0]): Parameters<CollisionDetection>[0] {
@@ -421,11 +438,13 @@ function PlaylistDetail() {
// sort, filters and grouping. All three are this component's own state, which is what makes a
// render-phase write legal here.
const [viewFor, setViewFor] = useState(selectedId);
const [bandWasUp, setBandWasUp] = useState(false);
if (viewFor !== selectedId) {
setViewFor(selectedId);
setViewSort(STORED_SORT);
setFilters({});
setGroupBy(false);
setBandWasUp(false);
}
useUrlMirror(
playlistViewParamUpdates(selectedId != null ? { sort: viewSort, filters, groupBy } : null),
@@ -515,6 +534,7 @@ function PlaylistDetail() {
);
const selectAllRef = useRef<HTMLInputElement>(null);
const bandRef = useRef<HTMLDivElement>(null);
const tableRef = useRef<HTMLTableElement>(null);
// The band's own "Move to" list, the row menu's with no row behind it.
const moveMenu = useContextMenu<"selection">();
const tableColumns = canEdit
@@ -541,6 +561,18 @@ function PlaylistDetail() {
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
// While anything is selected, shown or not: a tick only a filter hides still needs its way out.
const bandUp = canEdit && selection.shownSelected.length + selection.hiddenCount > 0;
if (bandUp && !bandWasUp) setBandWasUp(true);
// Out loud from a region that outlives the band: its news includes the band going.
const selectionStatus = bandUp
? [
t("playlists.selectedCount", { count: selection.shownSelected.length }),
...(selection.hiddenCount > 0
? [t("playlists.selectedHidden", { count: selection.hiddenCount })]
: []),
].join(" · ")
: bandWasUp
? t("playlists.selectionCleared")
: "";
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
// offers itself when the view is a pure reordering of the whole list.
const canSaveOrder = canEdit && !filterActive && !sameOrder(shown, items);
@@ -557,6 +589,13 @@ function PlaylistDetail() {
setFilters({});
setGroupBy(false);
}
// From a button that goes with the view: a focus on it would fall to <body>, and the next Tab start
// over at the top of the page.
const resetView = (e: MouseEvent<HTMLButtonElement>) => {
if (document.activeElement === e.currentTarget)
(selectAllRef.current ?? renameTarget.current)?.focus();
clearView();
};
function saveOrder() {
order.set(shown); // onApply persists it
clearView();
@@ -565,10 +604,11 @@ function PlaylistDetail() {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
// The grip was focusable and called itself "reorder", but only a pointer could move it.
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
keyboardCodes: viewActive ? NO_KEY_STARTS : undefined,
}),
);
// Under a view a key would pick the row up with nowhere to put it: only the rail is left to go to.
const pointerSensorOnly = sensors.slice(0, 1);
// Named and placed from `shownRef`, read at announcement time (see ui/sortableA11y).
const dndA11y = useSortableA11y((id) => {
@@ -582,11 +622,6 @@ function PlaylistDetail() {
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
function refreshAll(listId: number | null = selectedId) {
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
}
/** The rows on the drag image. A selected row carries the others; a key moves only its own row. */
const [carried, setCarried] = useState<readonly string[] | null>(null);
const carriedRef = useRef<readonly string[]>([]);
@@ -643,82 +678,10 @@ function PlaylistDetail() {
/** Takes a row out at once and says so in a toast whose Undo puts it back where it was (the
* X column became the Delete key + this toast; no confirm, because nothing is lost).
* @returns whether the item was actually removed — the player needs to know, because it must
* step to the next item (or close) only when the removal really happened. */
async function removeItem(video: Video, listIdArg?: number | null): Promise<boolean> {
// Defaulted here, not as `listId = selectedRef.current` in the signature: the React Compiler
// cannot reorder a ref read in a default parameter and skipped the component for it.
const listId = listIdArg === undefined ? selectedRef.current : listIdArg;
if (listId == null) return false;
// Only the list ON SCREEN has rows to take the item out of optimistically — the open player can
// be removing from the list it was launched from while another one is selected.
if (listId === selectedRef.current) {
// A removal isn't itself on the reorder undo stack, but it no longer THROWS AWAY that history:
// rebasing drops the item from every snapshot, so the stack stays valid for what's left.
const next = order.value.filter((v) => v.id !== video.id);
order.rebase((list) => list.filter((v) => v.id !== video.id));
lastSetRef.current = `${listId}:${idsKey(next)}`;
}
// …and out of the CACHED list too, not just the rows. Anything that rewrites that cache entry
// before the refetch lands handed the membership guard a list that still held the video — the
// player's own progress checkpoint does exactly that when a removal steps it to the next item —
// so the guard reset the order: the removed row came back and the undo history the rebase had
// kept went with it. (A failure resyncs below, which puts the row back where it belongs.)
const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
// The STORED order it stood in — the Undo puts it back after its neighbour there (the server's
// list, so a view sort on screen does not decide it; `reinsertAt`, so a change in between does
// not shift it off by the rows added or moved meanwhile).
const storedBefore = before?.items.map((v) => v.id) ?? [];
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
);
try {
await removePlaylistItem(qc, listRef(listId), video.id);
refreshAll(listId);
} catch (e) {
// Said here, as moveItemTo does, or a removal fails in silence (inside the full-screen player
// there is not even a row snapping back to show it) — unless `req` already put it in its error
// dialog, which `notifyActionError` checks.
notifyActionError(e, msg("playlists.removeFailed"));
// …and the row goes back NOW, not when the resync lands: a removal that failed because the
// server was unreachable has a resync that fails too, and the list kept showing the row as
// removed while it was still in the playlist.
if (before) qc.setQueryData(qk.playlist(listId), before);
refreshAll(listId);
return false;
}
notify({
level: "success",
message: msg("playlists.removedToast", { title: raw(video.title ?? "") }),
action: {
label: msg("common.undo"),
// ONE request that puts it back at its index (the server renumbers around it): an append
// plus a whole-list reorder would land it at the end whenever the second call failed. The
// index is worked out against the list as it is NOW, fetched fresh.
onClick: () =>
void qc
.fetchQuery({
queryKey: qk.playlist(listId),
queryFn: () => api.playlist(listId),
staleTime: 0,
})
.then((now) => {
const back = reinsertAt(
storedBefore,
[video.id],
now.items.map((v) => v.id),
);
if (back.keys.length)
return addPlaylistItem(qc, listRef(listId), video.id, back.positions[0]);
})
.catch((e: unknown) => notifyActionError(e, msg("playlists.undoRemoveFailed")))
.finally(() => {
refreshAll(listId);
qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) });
}),
},
});
return true;
* @returns whether the write went through — the player needs to know, because it must step to the
* next item (or close) only then. */
function removeItem(video: Video, listId?: number | null): Promise<boolean> {
return removeRows([video], tickOf(video), listId);
}
// The drag announcements read `shownRef` too: the CURRENT positions, without making their closures
// a render dependency of DndContext; `removeRef` keeps the memoized column callbacks stable.
@@ -756,13 +719,29 @@ function PlaylistDetail() {
linkedOutOfReach && selectedId === railMovedTo ? linkedOver : selectedId,
);
// Off the screen at once, and out of the cached list too, for the reason `removeItem` gives. Returns
// the cached list as it stood: the place an Undo goes back to, and what a failure puts back.
/** Off the screen at once. Returns the cached list as it stood: the place an Undo goes back to, and
* what a failure puts back. */
function takeOut(listId: number, ids: readonly string[]) {
const gone = new Set(ids);
const next = order.value.filter((v) => !gone.has(v.id));
order.rebase((list) => list.filter((v) => !gone.has(v.id)));
lastSetRef.current = `${listId}:${idsKey(next)}`;
// Only the list ON SCREEN has rows to take out optimistically — the open player can be removing
// from the list it was launched from while another one is selected.
if (listId === selectedRef.current) {
// Not itself on the reorder undo stack, but it no longer THROWS AWAY that history: rebasing
// drops the rows from every snapshot, so the stack stays valid for what's left.
const next = order.value.filter((v) => !gone.has(v.id));
order.rebase((list) => list.filter((v) => !gone.has(v.id)));
lastSetRef.current = `${listId}:${idsKey(next)}`;
// The table goes with its last row: a focus in it or in the band would fall to <body>, and the
// next Tab start over at the top of the page. The list's "…" takes it.
const held = document.activeElement;
if (!next.length && (tableRef.current?.contains(held) || bandRef.current?.contains(held)))
renameTarget.current?.focus();
}
// …and out of the CACHED list too, not just the rows. Anything that rewrites that cache entry
// before the refetch lands handed the membership guard a list that still held the video — the
// player's own progress checkpoint does exactly that when a removal steps it to the next item —
// so the guard reset the order: the removed row came back and the undo history the rebase had
// kept went with it. (A failure puts it back: `putBack`.)
const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
old ? { ...old, items: old.items.filter((v) => !gone.has(v.id)) } : old,
@@ -785,6 +764,36 @@ function PlaylistDetail() {
}
const titleOf = (rows: Video[], id: string | undefined) =>
raw(rows.find((v) => v.id === id)?.title ?? "");
// What an action on this one row hands a failure to tick again.
const tickOf = (v: Video) => (selection.isSelected(v.id) ? [v.id] : []);
// Where rows go back when their Undo comes: against the list as it is NOW, fetched fresh, and after
// their neighbours in the STORED order they stood in, so neither a view sort on screen nor a change
// in between shifts them (`reinsertAt`).
const placesNow = (listId: number, storedBefore: string[], ids: string[]) =>
qc
.fetchQuery({
queryKey: qk.playlist(listId),
queryFn: () => api.playlist(listId),
staleTime: 0,
})
.then((now) =>
reinsertAt(
storedBefore,
ids,
now.items.map((v) => v.id),
),
);
// An Undo that could not bring everything back says so: the server leaves out a video it cannot put
// back (gone on from the target, or from the catalog), and silence would read as all of them.
function sayWhatCameBack(asked: number, added: number) {
if (added >= asked) return;
notify({
level: "warning",
message: added
? msg("playlists.undoSomeBack", { count: added })
: msg("playlists.undoNoneBack"),
});
}
/** Move rows into another list, in ONE request (see the endpoint). `ticked`: the ones the selection
* held, which a failure ticks again. */
@@ -828,21 +837,11 @@ function PlaylistDetail() {
: msg("playlists.movedManyToast", { count: left.length, name }),
action: {
label: msg("common.undo"),
// The places worked out against the list as it is NOW, as in `removeItem`. A video back in
// the source by then stays where it is, in both lists.
// A video back in the source by then stays where it is, in both lists. One gone on from the
// target stays out: the server says how many came back.
onClick: () =>
void qc
.fetchQuery({
queryKey: qk.playlist(listId),
queryFn: () => api.playlist(listId),
staleTime: 0,
})
.then((now) => {
const back = reinsertAt(
storedBefore,
left,
now.items.map((v) => v.id),
);
void placesNow(listId, storedBefore, left)
.then((back) => {
const place = new Map(back.keys.map((id, i) => [id, back.positions[i]!]));
const keep = (list: string[]) => list.filter((id) => place.has(id));
const undoMoved = keep(moved);
@@ -852,7 +851,7 @@ function PlaylistDetail() {
moved: undoMoved,
already_there: undoHeld,
positions: [...undoMoved, ...undoHeld].map((id) => place.get(id)!),
});
}).then((r) => sayWhatCameBack(back.keys.length, r.added));
})
.catch((e: unknown) =>
notifyActionError(
@@ -865,81 +864,89 @@ function PlaylistDetail() {
});
}
/** Remove rows in ONE request, with an Undo that puts them back at their places in one too. */
async function removeRows(rows: Video[], ticked: readonly string[]) {
if (selectedId == null || !rows.length) return;
const listId = selectedId;
/** Remove rows in ONE request, with an Undo that puts them back at their places in one too: an
* append plus a whole-list reorder would land them at the end whenever the second call failed.
* `ticked`: the ones the selection held, which a failure ticks again.
* @returns whether the write went through. */
async function removeRows(
rows: Video[],
ticked: readonly string[],
listIdArg?: number | null,
): Promise<boolean> {
// Defaulted here, not as `listIdArg = selectedRef.current` in the signature: the React Compiler
// cannot reorder a ref read in a default parameter and skipped the component for it.
const listId = listIdArg === undefined ? selectedRef.current : listIdArg;
if (listId == null || !rows.length) return false;
const ids = rows.map((v) => v.id);
const list = listRef(listId);
const before = takeOut(listId, ids);
const failTitle = msg(
rows.length > 1 ? "playlists.removeManyFailed" : "playlists.removeFailed",
);
let removed: number | null = null;
let removed: string[] | null = null;
try {
removed = (await removePlaylistItems(qc, list, ids)).removed;
} catch (e) {
// Said here, or a removal fails in silence (inside the full-screen player there is not even a row
// snapping back to show it) — unless `req` already put it in its error dialog, which
// `notifyActionError` checks.
notifyActionError(e, failTitle);
}
refreshAfterEdit(ids);
if (removed == null) {
// …and the rows go back NOW, not when the resync lands: a removal that failed because the server
// was unreachable has a resync that fails too.
putBack(listId, before, ticked);
return;
return false;
}
if (removed === 0) {
// A video someone else took out meanwhile is not this removal's to bring back.
const went = removed;
if (!went.length) {
notify({ level: "warning", message: msg("playlists.removeNoneLeft") });
return;
return true;
}
const storedBefore = before?.items.map((v) => v.id) ?? [];
notify({
level: "success",
message:
removed === 1 && rows.length === 1
? msg("playlists.removedToast", { title: titleOf(rows, ids[0]) })
: msg("playlists.removedManyToast", { count: removed }),
went.length === 1 && rows.length === 1
? msg("playlists.removedToast", { title: titleOf(rows, went[0]) })
: msg("playlists.removedManyToast", { count: went.length }),
action: {
label: msg("common.undo"),
onClick: () =>
void qc
.fetchQuery({
queryKey: qk.playlist(listId),
queryFn: () => api.playlist(listId),
staleTime: 0,
})
.then((now) => {
const back = reinsertAt(
storedBefore,
ids,
now.items.map((v) => v.id),
);
void placesNow(listId, storedBefore, went)
.then((back) => {
if (back.keys.length)
return restorePlaylistItems(qc, list, back.keys, back.positions);
return restorePlaylistItems(qc, list, back.keys, back.positions).then((r) =>
sayWhatCameBack(back.keys.length, r.added),
);
})
.catch((e: unknown) =>
notifyActionError(
e,
msg(
ids.length > 1 ? "playlists.undoRemoveManyFailed" : "playlists.undoRemoveFailed",
went.length > 1 ? "playlists.undoRemoveManyFailed" : "playlists.undoRemoveFailed",
),
),
)
.finally(() => refreshAfterEdit(ids)),
.finally(() => refreshAfterEdit(went)),
},
});
return true;
}
// The band goes when the selection does. A focus inside it would fall to <body>, and the next Tab
// start over at the top of the page: the header's box takes it, or, when the action leaves no row
// and so no box, the list's "…".
function leaveBand(emptied = false) {
if (!bandRef.current?.contains(document.activeElement)) return;
(emptied ? renameTarget.current : selectAllRef.current)?.focus();
// start over at the top of the page: the header's box takes it (`takeOut` moves it on when the
// action leaves no row and so no box).
function leaveBand() {
if (bandRef.current?.contains(document.activeElement)) selectAllRef.current?.focus();
}
// The selection goes as the action starts: its rows are leaving, and a tick a filter hides would
// come back later on a row the reader has forgotten about. A failure ticks the rows acted on again.
function selectionRows() {
const rows = shown.filter((v) => selection.isSelected(v.id));
leaveBand(rows.length === items.length);
leaveBand();
selection.clear();
return rows;
}
@@ -1039,6 +1046,12 @@ function PlaylistDetail() {
// nothing to repeat: before the first move, or when the remembered list IS the one you are looking
// at, which is not a move at all. Nothing to explain, so nothing to leave behind greyed out.
const recentMoveTarget = moveTargets.find((p) => p.id === lastMoveTargetId);
// `playlists` is empty until the rail's list is in, too: no targets only once it says so.
const noTargetsNote = listQuery.data
? t("playlists.moveNoTargets")
: listQuery.isError
? t("playlists.moveTargetsFailed")
: t("playlists.moveTargetsLoading");
const storedOrderEditable = canEdit && !viewActive;
// While the rows must stay put the others make no way: no row is "over" anything.
const dragCollision: CollisionDetection = (args) =>
@@ -1059,6 +1072,10 @@ function PlaylistDetail() {
carriedRef.current.length > 1
? t("playlists.dragPickedMany", { count: carriedRef.current.length })
: base.onDragStart(args),
onDragCancel: (args: Parameters<typeof base.onDragCancel>[0]) =>
carriedRef.current.length > 1
? t("playlists.dragCancelledMany", { count: carriedRef.current.length })
: base.onDragCancel(args),
onDragMove: () => {
const id = railDropTarget();
return id == null
@@ -1067,7 +1084,9 @@ function PlaylistDetail() {
},
onDragEnd: (args: Parameters<typeof base.onDragEnd>[0]) =>
droppedOnRef.current == null
? base.onDragEnd(args)
? carriedRef.current.length > 1
? t("playlists.dragCancelledMany", { count: carriedRef.current.length })
: base.onDragEnd(args)
: t("playlists.dragDroppedOnList", { name: listNameOf.current(droppedOnRef.current) }),
},
};
@@ -1106,7 +1125,7 @@ function PlaylistDetail() {
label: t("playlists.moveToRecent", { name: plName(recentMoveTarget) }),
icon: <History className="w-4 h-4" />,
separatorBefore: true,
onSelect: () => void moveRows([ctxRow], recentMoveTarget),
onSelect: () => void moveRows([ctxRow], recentMoveTarget, tickOf(ctxRow)),
},
]
: []),
@@ -1121,7 +1140,7 @@ function PlaylistDetail() {
id: `move-${pl.id}`,
label: plName(pl),
icon: moveTargetIcon(pl),
onSelect: () => void moveRows([ctxRow], pl),
onSelect: () => void moveRows([ctxRow], pl, tickOf(ctxRow)),
})),
}
: // Kept visible and disabled rather than dropped: an action that vanishes teaches
@@ -1132,7 +1151,7 @@ function PlaylistDetail() {
icon: <FolderInput className="w-4 h-4" />,
separatorBefore: !recentMoveTarget,
disabled: true,
note: t("playlists.moveNoTargets"),
note: noTargetsNote,
onSelect: () => {},
},
// Same gate as the drag handle; the reason goes under the first of the four rows.
@@ -1167,7 +1186,7 @@ function PlaylistDetail() {
label: t("playlists.moveTo"),
icon: <FolderInput className="w-4 h-4" />,
disabled: true,
note: t("playlists.moveNoTargets"),
note: noTargetsNote,
onSelect: () => {},
},
];
@@ -1289,6 +1308,9 @@ function PlaylistDetail() {
return (
<>
<div className="px-4 pb-4">
<div role="status" className="sr-only">
{selectionStatus}
</div>
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */}
<PageToolbar>
@@ -1385,7 +1407,7 @@ function PlaylistDetail() {
leaveBand();
selection.clear();
}}
onClearView={viewActive ? clearView : undefined}
onClearView={viewActive ? resetView : undefined}
trailing={
<UndoToolbar
canUndo={order.canUndo}
@@ -1425,7 +1447,7 @@ function PlaylistDetail() {
{viewActive && (
<Button
variant="link-muted"
onClick={clearView}
onClick={resetView}
className="text-xs underline underline-offset-2"
>
{t("playlists.clearView")}
@@ -1477,7 +1499,7 @@ function PlaylistDetail() {
</div>
) : (
<DndContext
sensors={viewActive ? pointerSensorOnly : sensors}
sensors={sensors}
collisionDetection={dragCollision}
// Not the list, under a drag that is over the rail: it would run away beside it.
autoScroll={{ canScroll: () => !isOverRail() }}
@@ -1501,7 +1523,7 @@ function PlaylistDetail() {
{/* table-fixed, not auto: every column but the title has a declared width, so the
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
min-content, and a nowrap title cell's min-content is the entire title. */}
<table className="w-full table-fixed border-collapse text-sm">
<table ref={tableRef} className="w-full table-fixed border-collapse text-sm">
<ColumnHead
columns={tableColumns}
sort={viewSort}
@@ -1539,8 +1561,15 @@ function PlaylistDetail() {
return selection.clear();
},
removeSelected: () => {
if (!selection.shownSelected.length) return false;
removeSelected();
if (!bandUp) return false;
// Nothing happens to a row the reader cannot see, and neither to the
// row under the keyboard, which no one selected.
if (selection.shownSelected.length) removeSelected();
else
notify({
level: "warning",
message: msg("playlists.removeAllHidden"),
});
return true;
},
}
@@ -1571,12 +1600,13 @@ function PlaylistDetail() {
)}
</div>
{/* On <body>: the page's scroller contains anything fixed, and the rail lies outside it.
Above every layer (the tooltip's level), and no way back to fly: it drops or stays. Out
of the pointer's way: lying under it, it kept the rail's tab from peeking open. */}
{createPortal(
Above every layer (the tooltip's level, by its token: dnd-kit's `zIndex` takes a number
only, and its `style` comes after it), and no way back to fly: it drops or stays. Out of
the pointer's way: lying under it, it kept the rail's tab from peeking open. */}
<Overlay>
<DragOverlay
dropAnimation={null}
zIndex={100}
style={{ zIndex: "var(--z-index-tooltip)" }}
className="pointer-events-none"
modifiers={[beneathPointer]}
>
@@ -1589,9 +1619,8 @@ function PlaylistDetail() {
: shown.find((v) => v.id === carried[0])?.title}
</div>
)}
</DragOverlay>,
document.body,
)}
</DragOverlay>
</Overlay>
</DndContext>
)}