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
+3 -3
View File
@@ -70,10 +70,10 @@ export const playlistsApi = {
req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
// `position` (an index in the stored order) puts it back where a removal took it from — the
// list page's Undo; omitted, it is appended.
addToPlaylist: (id: number, videoId: string, position?: number) =>
addToPlaylist: (id: number, videoId: string) =>
req(`/api/playlists/${id}/items`, {
method: "POST",
body: JSON.stringify({ video_id: videoId, ...(position != null && { position }) }),
body: JSON.stringify({ video_id: videoId }),
}),
removeFromPlaylist: (id: number, videoId: string) =>
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
@@ -115,7 +115,7 @@ export const playlistsApi = {
removeManyFromPlaylist: (
id: number,
videoIds: string[],
): Promise<{ ok: true; playlist_id: number; removed: number }> =>
): Promise<{ ok: true; playlist_id: number; removed: string[] }> =>
req(`/api/playlists/${id}/items/remove-bulk`, {
method: "POST",
body: JSON.stringify({ video_ids: videoIds }),
+23 -1
View File
@@ -13,15 +13,17 @@ afterEach(cleanup);
function Rows({
selection,
onDelete,
rows = 3,
}: {
selection?: NavSelection;
onDelete?: (index: number) => void;
rows?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true, selection, onDelete });
return (
<div ref={ref}>
{[0, 1, 2].map((i) => (
{Array.from({ length: rows }, (_, i) => (
<div key={i} {...navItemAttrs(i)} data-testid={`r${i}`}>
<button data-testid={`r${i}-action`}>action</button>
<input data-testid={`r${i}-input`} />
@@ -125,6 +127,26 @@ describe("useListNav · selecting", () => {
await waitFor(() => expect(document.activeElement).toBe(screen.getByTestId("r2")));
});
it("goes on to the next row left, past what a selection took from above it", async () => {
const s = selection();
s.removeSelected.mockImplementation(() => {
// As a commit does it, after the key's handler: the two rows go, and the ones left are numbered
// from the top.
queueMicrotask(() => {
screen.getByTestId("r0").remove();
screen.getByTestId("r1").remove();
screen.getByTestId("r2").setAttribute("data-nav-index", "0");
screen.getByTestId("r3").setAttribute("data-nav-index", "1");
});
return true;
});
render(<Rows selection={s} rows={4} />);
const r2 = screen.getByTestId("r2");
screen.getByTestId("r1-box").focus();
press("Delete");
await waitFor(() => expect(document.activeElement).toBe(r2));
});
it("leaves the focus where it is when something else took it meanwhile", async () => {
render(<Rows />);
const leaving = screen.getByTestId("r1");
+9 -3
View File
@@ -476,10 +476,14 @@ function onKey(e: KeyboardEvent): void {
// How long a removal gets to take its row out of the DOM before the focus gives up following it.
const REMOVE_FRAMES = 30;
/** After the focused item was removed, focus the item now in its slot (the next one; the new last
* when it was the last), so a second Delete takes the next row — the way a mail list behaves. Only
* while nothing else took the focus meanwhile: the removal fell back to <body>, nothing more. */
/** After the focused item was removed, focus the item that followed it and is still there (the new
* last when none is), so a second Delete takes the next row — the way a mail list behaves. By element:
* rows a selection took from above it would shift an index past the ones left. Only while nothing else
* took the focus meanwhile: the removal fell back to <body>, nothing more. */
function refocusSlot(list: NavList, index: number, removed: HTMLElement): void {
const items = ownItems(list.el);
const at = items.indexOf(removed);
const after = at < 0 ? [] : items.slice(at + 1);
let frames = 0;
const wait = () => {
if (removed.isConnected) {
@@ -487,6 +491,8 @@ function refocusSlot(list: NavList, index: number, removed: HTMLElement): void {
return;
}
if (!focusIsUnclaimed()) return;
const next = after.find((el) => el.isConnected);
if (next) return focusItem(next);
const n = list.count();
if (n > 0) focusIndex(list, Math.min(index, n - 1));
};