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
+50
View File
@@ -94,6 +94,50 @@ async function pinOnTouch(page: Page, project: string) {
});
}
test.describe("acting on the selected rows", { tag: "@playlists" }, () => {
test.afterEach(async ({ page }) => {
for (const id of made.splice(0)) await page.request.delete(`/api/playlists/${String(id)}`);
});
const box = (page: Page, title: string) =>
page.getByRole("checkbox", { name: `Select “${title}”` });
test("a Shift range, the band's Remove, and its Undo back to the places", async ({
page,
}, info) => {
const { videos, from } = await setUp(page, `band ${info.project.name}`, info.project.name);
await box(page, videos[0]!.title).click();
await box(page, videos[1]!.title).click({ modifiers: ["Shift"] });
await expect(box(page, videos[1]!.title)).toBeChecked();
await expect(box(page, videos[2]!.title)).not.toBeChecked();
await page.getByRole("button", { name: "Remove", exact: true }).click();
await expect(page.locator("tbody tr")).toHaveCount(1);
await expect.poll(() => contents(page, from)).toEqual([videos[2]!.id]);
await page.getByRole("button", { name: "Undo", exact: true }).last().click();
await expect.poll(() => contents(page, from)).toEqual(videos.map((v) => v.id));
await expect(page.locator("tbody tr")).toHaveCount(3);
});
test("x ticks the walked row, Esc clears, and Delete takes the selection and goes on", async ({
page,
}, info) => {
const { videos, from } = await setUp(page, `keys ${info.project.name}`, info.project.name);
const firstRow = page.locator("tbody tr").first();
await firstRow.focus();
await page.keyboard.press("x");
await expect(box(page, videos[0]!.title)).toBeChecked();
await page.keyboard.press("Escape");
await expect(box(page, videos[0]!.title)).not.toBeChecked();
await page.keyboard.press("x");
await page.keyboard.press("j");
await page.keyboard.press("x");
await page.keyboard.press("Delete");
await expect.poll(() => contents(page, from)).toEqual([videos[2]!.id]);
await expect(page.locator("tbody tr")).toHaveCount(1);
await expect(firstRow).toBeFocused();
});
});
test.describe("dragging onto a list", { tag: "@playlists" }, () => {
test.afterEach(async ({ page }) => {
await page.mouse.up();
@@ -139,6 +183,8 @@ test.describe("dragging onto a list", { tag: "@playlists" }, () => {
await carryToRail(page, grip, target);
const image = page.getByText("2 videos", { exact: true });
await expect(image).toBeVisible();
// Above every layer, by the tooltip's token: the overlay wrapping it takes the level.
expect(await image.evaluate((el) => getComputedStyle(el.parentElement!).zIndex)).toBe("100");
// Below the pointer, so it leaves the list's name to read.
const { y } = await centre(target);
expect((await image.boundingBox())!.y).toBeGreaterThan(y);
@@ -184,6 +230,9 @@ test.describe("dragging onto a list", { tag: "@playlists" }, () => {
`drag cancel ${info.project.name}`,
info.project.name,
);
// A tick on another row: the Esc is the drag's, and leaves the selection alone.
const ticked = page.getByRole("checkbox", { name: `Select “${videos[2]!.title}”` });
await ticked.click();
const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false });
await carryToRail(page, grip, target);
await expect(target).toHaveClass(/ring-2/);
@@ -192,6 +241,7 @@ test.describe("dragging onto a list", { tag: "@playlists" }, () => {
await page.mouse.up();
expect(await contents(page, from)).toEqual(videos.map((v) => v.id));
expect(await contents(page, to)).toEqual([]);
await expect(ticked).toBeChecked();
});
test("a key still reorders a row, alone", async ({ page }, info) => {
@@ -94,10 +94,13 @@ describe("videoWrites", () => {
});
it("marks a bulk removal from Watch later, and its restore", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({ ok: true } as never);
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
ok: true,
removed: ["X"],
} as never);
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await removePlaylistItems(qc, WATCH_LATER, ["X"]);
await removePlaylistItems(qc, WATCH_LATER, ["X", "gone"]);
expect(marks(qc)).toEqual({ X: { saved: false } });
await restorePlaylistItems(qc, WATCH_LATER, ["X"], [0]);
expect(marks(qc)).toEqual({ X: { saved: true } });
+4 -6
View File
@@ -43,10 +43,8 @@ const markIfWatchLater = (qc: QueryClient, list: ListRef, id: string, saved: boo
if (list.kind === "watch_later") markVideo(qc, id, { saved });
};
/** Add a video to a playlist; `position` (an index in the stored order) puts it back where a
* removal took it from. */
export function addPlaylistItem(qc: QueryClient, list: ListRef, id: string, position?: number) {
return api.addToPlaylist(list.id, id, position).then((r) => {
export function addPlaylistItem(qc: QueryClient, list: ListRef, id: string) {
return api.addToPlaylist(list.id, id).then((r) => {
markIfWatchLater(qc, list, id, true);
return r;
});
@@ -59,10 +57,10 @@ export function removePlaylistItem(qc: QueryClient, list: ListRef, id: string) {
});
}
/** Take videos out of a playlist, in one request. */
/** Take videos out of a playlist, in one request. Marks what the server says went. */
export function removePlaylistItems(qc: QueryClient, list: ListRef, ids: string[]) {
return api.removeManyFromPlaylist(list.id, ids).then((r) => {
for (const id of ids) markIfWatchLater(qc, list, id, false);
for (const id of r.removed) markIfWatchLater(qc, list, id, false);
return r;
});
}
+1 -1
View File
@@ -35,7 +35,7 @@
"listStep": "Next / previous item",
"gridMove": "Move around the list",
"open": "Open the current item",
"listRemove": "Remove the selected rows, or else the current one",
"listRemove": "Remove from the list",
"listSelect": "Select or deselect the current item",
"listClearSelection": "Clear the selection",
"cap": {
@@ -117,6 +117,15 @@
"removedManyToast_other": "{{count}} videos removed from the playlist",
"moveNoneLeft": "Nothing moved: they aren’t in this playlist any more",
"removeNoneLeft": "Nothing removed: they aren’t in this playlist any more",
"selectionCleared": "Selection cleared",
"removeAllHidden": "The selected rows are hidden by the filter",
"undoNoneBack": "Nothing came back: those videos aren’t where they were any more",
"undoSomeBack_one": "Only {{count}} video came back: the rest aren’t where they were any more",
"undoSomeBack_other": "Only {{count}} videos came back: the rest aren’t where they were any more",
"dragCancelledMany_one": "Move cancelled; {{count}} video stays where it was",
"dragCancelledMany_other": "Move cancelled; {{count}} videos stay where they were",
"moveTargetsFailed": "Your playlists couldn’t be loaded",
"moveTargetsLoading": "Loading your playlists…",
"moveManyFailed": "Couldn't move the videos",
"removeManyFailed": "Couldn't remove the videos",
"undoMoveFailed": "Couldn't move the video back",
+2 -2
View File
@@ -35,8 +35,8 @@
"listStep": "Következő / előző elem",
"gridMove": "Mozgás a listában",
"open": "Az aktuális elem megnyitása",
"listRemove": "A kijelölt sorok, vagy az aktuális eltávolítása",
"listSelect": "Az aktuális elem kijelölése vagy levétele",
"listRemove": "Eltávolítás a listából",
"listSelect": "Az aktuális elem kijelölése, vagy a kijelölés törlése",
"listClearSelection": "A kijelölés törlése",
"cap": {
"space": "Szóköz",
@@ -117,6 +117,15 @@
"removedManyToast_other": "{{count}} videó eltávolítva a listáról",
"moveNoneLeft": "Nem történt áthelyezés: ezek már nincsenek ebben a listában",
"removeNoneLeft": "Nem történt eltávolítás: ezek már nincsenek ebben a listában",
"selectionCleared": "Kijelölés törölve",
"removeAllHidden": "A kijelölt sorokat a szűrő elrejti",
"undoNoneBack": "Semmi nem került vissza: ezek a videók már nincsenek ott, ahol voltak",
"undoSomeBack_one": "Csak {{count}} videó került vissza: a többi már nincs ott, ahol volt",
"undoSomeBack_other": "Csak {{count}} videó került vissza: a többi már nincs ott, ahol volt",
"dragCancelledMany_one": "Áthelyezés megszakítva; {{count}} videó a helyén marad",
"dragCancelledMany_other": "Áthelyezés megszakítva; {{count}} videó a helyén marad",
"moveTargetsFailed": "Nem sikerült betölteni a listáidat",
"moveTargetsLoading": "A listáid betöltése…",
"moveManyFailed": "Nem sikerült áthelyezni a videókat",
"removeManyFailed": "Nem sikerült eltávolítani a videókat",
"undoMoveFailed": "Nem sikerült visszahelyezni a videót",
+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));
};
@@ -148,6 +148,9 @@ const list = (id: number, items: Video[]) => ({
items,
});
/** The server's answer to a removal: the rows that went. */
const wentOut = (...removed: string[]) => ({ ok: true, playlist_id: 1, removed });
let qc: QueryClient;
function mount(
@@ -158,17 +161,21 @@ function mount(
detail,
linked = h.selectedId,
listIn = false,
rail,
}: {
strictOverCache?: boolean;
detail?: (id: number) => Promise<unknown>;
linked?: number | null;
/** The account's list already in, as the rail that fetched it leaves it. */
listIn?: boolean;
/** What the account's list answers, when it is not the lists above. */
rail?: () => Promise<unknown>;
} = {},
) {
h.linkedId = linked;
vi.spyOn(api, "playlists").mockResolvedValue(
Object.entries(lists).map(([id, items]) => list(Number(id), items)) as never,
vi.spyOn(api, "playlists").mockImplementation(
(rail ??
(async () => Object.entries(lists).map(([id, items]) => list(Number(id), items)))) as never,
);
vi.spyOn(api, "playlist").mockImplementation(
(detail ?? (async (id: number) => list(id, lists[id] ?? []))) as never,
@@ -409,7 +416,7 @@ describe("Playlists — removing a video from inside the player", () => {
it("removes it from the list the player was launched from, even after the selection moved", async () => {
// The selection can change under an open player (the command palette opens over it); the
// removal read the LIVE selection, so it took the video out of a list the viewer never touched.
const remove = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("V") as never);
const { rerender } = mount({ 1: [vid("V", 0)], 2: [vid("V", 0), vid("W", 0)] });
await play("Video V");
await waitFor(() => expect(h.player).not.toBeNull());
@@ -419,7 +426,7 @@ describe("Playlists — removing a video from inside the player", () => {
await act(async () => {
await h.player!.onRemoveFromList!(vid("V", 0));
});
expect(remove).toHaveBeenCalledWith(1, "V");
expect(remove).toHaveBeenCalledWith(1, ["V"]);
// …and the list on screen keeps its own copy of V: nothing was taken out of it.
expect(screen.getByRole("button", { name: "card.play — Video V" })).toBeTruthy();
});
@@ -427,7 +434,9 @@ describe("Playlists — removing a video from inside the player", () => {
it("says so when the removal fails because the list is gone", async () => {
// `req` leaves 403/404 to the caller; the catch assumed a global dialog had already reported
// it, so a confirmed removal failed in total silence behind the full-screen player.
vi.spyOn(api, "removeFromPlaylist").mockRejectedValue(new HttpError(404, "Unknown playlist"));
vi.spyOn(api, "removeManyFromPlaylist").mockRejectedValue(
new HttpError(404, "Unknown playlist"),
);
mount({ 1: [vid("V", 0)] });
await play("Video V");
await waitFor(() => expect(h.player).not.toBeNull());
@@ -458,7 +467,7 @@ describe("Playlists — removing a video from inside the player", () => {
// The player steps to the next item after a removal and stores the last one's position into
// every cached list. Landing before the refetch, that write handed the page a list still holding
// the video, and the page — seeing a different set of ids — reset: the row came back.
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("V") as never);
mount({ 1: [vid("V", 0), vid("W", 0)] });
await play("Video V");
await waitFor(() => expect(h.player).not.toBeNull());
@@ -681,12 +690,12 @@ describe("Playlists — a change the server refused is taken back on screen", ()
it("puts back a row whose removal failed, even when the resync fails too", async () => {
// A removal during an API restart: the DELETE fails, and so does the refetch meant to put the row
// back — the list kept showing it as removed while it was still in the playlist.
vi.spyOn(api, "removeFromPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
vi.spyOn(api, "removeManyFromPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
mount({ 1: [vid("V", 0), vid("W", 0)] });
await screen.findByRole("button", { name: "card.play — Video V" });
vi.mocked(api.playlist).mockRejectedValue(new TypeError("Failed to fetch"));
await rowMenu("Video V", "playlists.removeItem");
await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalled());
await waitFor(() => expect(api.removeManyFromPlaylist).toHaveBeenCalled());
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
expect(h.notify).toHaveBeenCalledWith(
@@ -700,10 +709,13 @@ describe("Playlists — a change the server refused is taken back on screen", ()
describe("Playlists — removing a row, and undoing it", () => {
// The server's side of a removal, so the refetch after it agrees with it.
const serverRemoves = (lists: Record<number, Video[]>) =>
vi.spyOn(api, "removeFromPlaylist").mockImplementation(async (id: number, videoId: string) => {
lists[id] = lists[id]!.filter((v) => v.id !== videoId);
return undefined as never;
});
vi.spyOn(api, "removeManyFromPlaylist").mockImplementation((async (
id: number,
videoIds: string[],
) => {
lists[id] = lists[id]!.filter((v) => !videoIds.includes(v.id));
return wentOut(...videoIds);
}) as never);
// The removal's toast — found by what it says, not by being the latest: a refetch after the
// removal may notify too.
const lastToast = () =>
@@ -727,10 +739,10 @@ describe("Playlists — removing a row, and undoing it", () => {
it("removes from the menu at once, and the toast's Undo puts it back at its place", async () => {
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
serverRemoves(lists);
vi.spyOn(api, "addToPlaylist").mockResolvedValue(undefined as never);
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true, added: 1 } as never);
mount(lists);
await rowMenu("Video B", "playlists.removeItem");
await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalledWith(1, "B"));
await waitFor(() => expect(api.removeManyFromPlaylist).toHaveBeenCalledWith(1, ["B"]));
expect(rowsOnScreen()).toEqual(["Video A", "Video C"]);
// No confirm: the toast's Undo is the safety net.
expect(h.confirm).not.toHaveBeenCalled();
@@ -740,13 +752,13 @@ describe("Playlists — removing a row, and undoing it", () => {
expect(lastToast().action?.label).toEqual(msg("common.undo"));
act(() => lastToast().action!.onClick());
// Index 1 of the STORED order: where it was, not the end of the list.
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "B", 1));
await waitFor(() => expect(api.restoreToPlaylist).toHaveBeenCalledWith(1, ["B"], [1]));
});
it("puts it back where it stood in the stored order, whatever the view on screen", async () => {
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
serverRemoves(lists);
vi.spyOn(api, "addToPlaylist").mockResolvedValue(undefined as never);
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true, added: 1 } as never);
mount(lists);
await screen.findByRole("button", { name: "card.play — Video C" });
// Sort the VIEW by title, descending: C, B, A on screen — the stored order is still A, B, C.
@@ -756,7 +768,7 @@ describe("Playlists — removing a row, and undoing it", () => {
await rowMenu("Video A", "playlists.removeItem");
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
act(() => lastToast().action!.onClick());
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "A", 0));
await waitFor(() => expect(api.restoreToPlaylist).toHaveBeenCalledWith(1, ["A"], [0]));
});
// The stored index drifts when the list changes before the Undo (a row added at
@@ -764,19 +776,45 @@ describe("Playlists — removing a row, and undoing it", () => {
it("puts it back after its neighbour when the list changed before the Undo", async () => {
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
serverRemoves(lists);
vi.spyOn(api, "addToPlaylist").mockResolvedValue(undefined as never);
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true, added: 1 } as never);
mount(lists);
await rowMenu("Video B", "playlists.removeItem");
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
lists[1] = [vid("X", 0), ...lists[1]!];
act(() => lastToast().action!.onClick());
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "B", 2));
await waitFor(() => expect(api.restoreToPlaylist).toHaveBeenCalledWith(1, ["B"], [2]));
});
it("says so when the video could not come back, rather than nothing at all", async () => {
const lists = { 1: [vid("A", 0), vid("B", 0)] };
serverRemoves(lists);
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true, added: 0 } as never);
mount(lists);
await rowMenu("Video A", "playlists.removeItem");
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
act(() => lastToast().action!.onClick());
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "warning", message: msg("playlists.undoNoneBack") }),
),
);
});
it("refreshes what Add to playlist knows about a removed video", async () => {
const lists = { 1: [vid("A", 0), vid("B", 0)] };
serverRemoves(lists);
mount(lists);
await screen.findByRole("button", { name: "card.play — Video A" });
qc.setQueryData(qk.playlistMembership("A"), { in: [1] });
await rowMenu("Video A", "playlists.removeItem");
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
expect(qc.getQueryState(qk.playlistMembership("A"))?.isInvalidated).toBe(true);
});
it("says so when the Undo fails", async () => {
const lists = { 1: [vid("A", 0), vid("B", 0)] };
serverRemoves(lists);
vi.spyOn(api, "addToPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
vi.spyOn(api, "restoreToPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
mount(lists);
await rowMenu("Video A", "playlists.removeItem");
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
@@ -797,20 +835,20 @@ describe("Playlists — removing a row, and undoing it", () => {
pressKey("j");
expect(document.activeElement?.textContent).toContain("Video B");
pressKey("Delete");
await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalledWith(1, "B"));
await waitFor(() => expect(api.removeManyFromPlaylist).toHaveBeenCalledWith(1, ["B"]));
expect(rowsOnScreen()).toEqual(["Video A", "Video C"]);
await waitFor(() => expect(document.activeElement?.textContent).toContain("Video C"));
});
it("offers no Delete, no Remove and no X on Watch later's rows", async () => {
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut() as never);
h.selectedId = WATCH_LATER;
mount({ [WATCH_LATER]: [vid("A", 0), vid("B", 0)] });
await keysLiveOn(await screen.findByRole("button", { name: "card.play — Video B" }));
pressKey("j");
pressKey("Delete");
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(api.removeFromPlaylist).not.toHaveBeenCalled();
expect(api.removeManyFromPlaylist).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: /removeItemOf/ })).toBeNull();
});
@@ -1126,7 +1164,7 @@ describe("Playlists — selecting rows", () => {
});
it("drops a row removed from the list out of the selection", async () => {
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A") as never);
mount(three);
fireEvent.click(await box("Video A"));
fireEvent.click(await box("Video B"));
@@ -1394,6 +1432,7 @@ describe("Playlists — dragging rows", () => {
});
describe("under a sort or a filter", () => {
const keyStarts = () => dnd.props.sensors[1].options.keyboardCodes?.start;
const filter = async (value: string) => {
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
fireEvent.change(await screen.findByPlaceholderText("playlists.colTitle"), {
@@ -1422,9 +1461,9 @@ describe("Playlists — dragging rows", () => {
const order = vi.spyOn(api, "reorderPlaylist").mockResolvedValue({} as never);
mount(abc);
await aRow("A");
expect(dnd.props.sensors).toHaveLength(2);
expect(keyStarts()).toBeUndefined();
await filter("Video");
expect(dnd.props.sensors).toHaveLength(1);
expect(keyStarts()).toEqual([]);
overNothing();
pickUp("A");
expect(collide()).toEqual([]);
@@ -1433,6 +1472,33 @@ describe("Playlists — dragging rows", () => {
expect(order).not.toHaveBeenCalled();
});
it.each([
["a sort", "plsort=title:desc"],
["a grouping", "plgroup=1"],
])("holds under %s as under a filter", async (_view, query) => {
window.history.replaceState(null, "", `/?page=playlists&pl=1&${query}`);
const order = vi.spyOn(api, "reorderPlaylist").mockResolvedValue({} as never);
mount(abc);
await aRow("A");
expect(keyStarts()).toEqual([]);
await screen.findByRole("button", { name: "playlists.dragHandleToList:Video A" });
overNothing();
pickUp("A");
expect(collide()).toEqual([]);
dropOn("A");
await new Promise((r) => setTimeout(r, 20));
expect(order).not.toHaveBeenCalled();
});
it("keeps the same sensors, so a view turning on logs no changed dependency list", async () => {
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
mount(abc);
await aRow("A");
await filter("Video");
expect(logged.mock.calls.filter((c) => String(c[0]).includes("changed size"))).toEqual([]);
logged.mockRestore();
});
it("explains the grip with no other list to go to", async () => {
mount({ 1: abc[1]! });
await aRow("A");
@@ -1442,9 +1508,56 @@ describe("Playlists — dragging rows", () => {
});
});
it("spends the drag's Esc on the drag: the ticks stay, and the next Esc clears them", async () => {
mount(abc);
await tick("Video A", "Video B");
overNothing();
pickUp("A");
const esc = () =>
act(() => {
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
);
});
esc();
act(() => {
dnd.props.onDragCancel();
});
expect(((await box("Video A")) as HTMLInputElement).checked).toBe(true);
expect(((await box("Video B")) as HTMLInputElement).checked).toBe(true);
esc();
expect(((await box("Video A")) as HTMLInputElement).checked).toBe(false);
});
it("leaves the list where it is while the drag looks for a list in the rail", async () => {
mount(abc);
await aRow("A");
overRail(2);
pickUp("A");
expect(dnd.props.autoScroll.canScroll()).toBe(false);
act(() => {
dnd.props.onDragCancel();
});
overNothing();
pickUp("A");
expect(dnd.props.autoScroll.canScroll()).toBe(true);
});
describe("what a screen reader hears", () => {
const say = () => dnd.props.accessibility.announcements;
it("counts them when many stay put, whichever way the drag ends", async () => {
mount(abc);
await tick("Video A", "Video B");
overNothing();
pickUp("A");
expect(say().onDragCancel({ active: { id: "A" } })).toBe("playlists.dragCancelledMany");
dropOn("A");
expect(say().onDragEnd({ active: { id: "A" }, over: null })).toBe(
"playlists.dragCancelledMany",
);
});
it("names how many are picked up, and the list under the drag", async () => {
mount(abc);
await tick("Video A", "Video B");
@@ -1509,6 +1622,14 @@ describe("Playlists — acting on the selected rows", () => {
action?: { onClick: () => void };
};
const button = (name: string) => screen.findByRole("button", { name });
const menuRow = (label: string) =>
waitFor(() => {
const row = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === label);
if (!row) throw new Error(`no menu row "${label}"`);
return row;
});
const moved = (ids: string[], already_there: string[] = []) => ({
ok: true,
from_playlist_id: 1,
@@ -1527,7 +1648,7 @@ describe("Playlists — acting on the selected rows", () => {
expect(screen.queryByText("playlists.selectedCount")).toBeNull();
expect(screen.getByRole("checkbox", { name: "playlists.groupByChannel" })).toBeTruthy();
await tick("Video A");
expect(await screen.findByText("playlists.selectedCount")).toBeTruthy();
expect(await screen.findAllByText("playlists.selectedCount")).toBeTruthy();
expect(screen.queryByRole("checkbox", { name: "playlists.groupByChannel" })).toBeNull();
fireEvent.click(await button("playlists.clearSelection"));
expect(screen.queryByText("playlists.selectedCount")).toBeNull();
@@ -1659,9 +1780,11 @@ describe("Playlists — acting on the selected rows", () => {
const lists = { ...abc };
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockImplementation((async () => {
lists[1] = lists[1]!.filter((v) => v.id === "B");
return { ok: true, playlist_id: 1, removed: 2 };
return wentOut("A", "C");
}) as never);
const restore = vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true } as never);
const restore = vi
.spyOn(api, "restoreToPlaylist")
.mockResolvedValue({ ok: true, added: 2 } as never);
mount(lists);
await tick("Video A", "Video C");
fireEvent.click(await button("playlists.removeSelected"));
@@ -1686,11 +1809,7 @@ describe("Playlists — acting on the selected rows", () => {
});
it("names the one video when one was removed", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
ok: true,
playlist_id: 1,
removed: 1,
} as never);
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("B") as never);
mount(abc);
await tick("Video B");
fireEvent.click(await button("playlists.removeSelected"));
@@ -1701,25 +1820,22 @@ describe("Playlists — acting on the selected rows", () => {
it("has Delete take the selection when there is one, with the focus on a box", async () => {
const remove = vi
.spyOn(api, "removeManyFromPlaylist")
.mockResolvedValue({ ok: true, playlist_id: 1, removed: 2 } as never);
const single = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
.mockResolvedValue(wentOut("A", "B") as never);
mount(abc);
await tick("Video A", "Video B");
(await box("Video B")).focus();
await act(async () => press("Delete"));
await waitFor(() => expect(remove).toHaveBeenCalledTimes(1));
expect(single).not.toHaveBeenCalled();
expect([...remove.mock.calls[0]![1]].sort()).toEqual(["A", "B"]);
});
it("has Delete take the walked row when nothing is selected", async () => {
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({} as never);
const single = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A") as never);
mount(abc);
await box("Video A");
press("j");
await act(async () => press("Delete"));
await waitFor(() => expect(single).toHaveBeenCalledWith(1, "A"));
expect(remove).not.toHaveBeenCalled();
await waitFor(() => expect(remove).toHaveBeenCalledWith(1, ["A"]));
});
it("keeps a row's own menu on that row, and leaves the selection alone", async () => {
@@ -1751,9 +1867,7 @@ describe("Playlists — acting on the selected rows", () => {
});
it("acts on the rows shown, says how many a filter hides, and keeps the view's reset", async () => {
const remove = vi
.spyOn(api, "removeManyFromPlaylist")
.mockResolvedValue({ ok: true, playlist_id: 1, removed: 1 } as never);
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("B") as never);
mount(abc);
await tick("Video A", "Video B");
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
@@ -1767,11 +1881,7 @@ describe("Playlists — acting on the selected rows", () => {
});
it("forgets the ticks a filter hid once it acted, too", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
ok: true,
playlist_id: 1,
removed: 1,
} as never);
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("B") as never);
mount(abc);
await tick("Video A", "Video B");
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
@@ -1808,7 +1918,7 @@ describe("Playlists — acting on the selected rows", () => {
const count = (await screen.findByText(/playlists\.itemCount/)).parentElement!;
expect(count.classList.contains("invisible")).toBe(false);
await tick("Video A");
const band = (await screen.findByText("playlists.selectedCount")).closest("[role=status]")!;
const band = await button("playlists.clearSelection");
expect(count.classList.contains("invisible")).toBe(true);
expect(count.getAttribute("aria-hidden")).toBe("true");
expect(count.parentElement).toBe(band.parentElement!.parentElement!.parentElement);
@@ -1878,11 +1988,7 @@ describe("Playlists — acting on the selected rows", () => {
});
it("hands the focus to the list's menu when the band's action emptied the list", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
ok: true,
playlist_id: 1,
removed: 1,
} as never);
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A") as never);
mount({ 1: [vid("A", 0)] });
await tick("Video A");
const remove = await button("playlists.removeSelected");
@@ -1892,6 +1998,237 @@ describe("Playlists — acting on the selected rows", () => {
expect(document.activeElement).toBe(screen.getByRole("button", { name: "playlists.railMenu" }));
});
it.each([
["the header's box", () => screen.getByRole("checkbox", { name: "playlists.selectAllShown" })],
["a row's box", () => screen.getByRole("checkbox", { name: "playlists.selectRow:Video A" })],
])("hands the focus to the list's menu when Delete on %s emptied the list", async (_n, held) => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A") as never);
mount({ 1: [vid("A", 0)] });
await tick("Video A");
held().focus();
await act(async () => press("Delete"));
await waitFor(() => expect(toast().action).toBeTruthy());
expect(document.activeElement).toBe(screen.getByRole("button", { name: "playlists.railMenu" }));
});
it("goes on to the next row left, past the rows taken from above the focus", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A", "B", "C") as never);
mount({ 1: ["A", "B", "C", "D", "E"].map((id) => vid(id, 0)) });
await tick("Video A", "Video B", "Video C");
(await box("Video C")).focus();
await act(async () => press("Delete"));
await waitFor(() => expect(document.activeElement?.textContent).toContain("Video D"));
});
it("takes no row at all on Delete when a filter hides every tick, and says why", async () => {
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut() as never);
mount(abc);
await tick("Video A");
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
fireEvent.change(await screen.findByPlaceholderText("playlists.colTitle"), {
target: { value: "Video B" },
});
await screen.findByText("playlists.selectedHidden");
(await screen.findByRole("button", { name: "card.play — Video B" })).focus();
await act(async () => press("Delete"));
expect(toast().message).toEqual(msg("playlists.removeAllHidden"));
expect(remove).not.toHaveBeenCalled();
expect(rowsOnScreen()).toEqual(["Video B"]);
});
it.each([
["the band's", true],
["the meta line's", false],
])("hands the focus on when %s Reset view goes with the view", async (_n, selecting) => {
window.history.replaceState(null, "", "/?page=playlists&pl=1&plsort=title:desc");
mount(abc);
if (selecting) await tick("Video A");
const reset = await button("playlists.clearView");
reset.focus();
fireEvent.click(reset);
await waitFor(() =>
expect(screen.queryByRole("button", { name: "playlists.clearView" })).toBeNull(),
);
expect(document.activeElement).toBe(
screen.getByRole("checkbox", { name: "playlists.selectAllShown" }),
);
});
// The answer comes later than the render, as a server's does: until then the row has left the list,
// and its tick with it.
it.each([
["move", "moveManyToPlaylist", "playlists.moveToRecent"],
["removal", "removeManyFromPlaylist", "playlists.removeItem"],
] as const)("ticks a row again when its own menu's %s failed", async (_n, call, item) => {
let fail: (e: unknown) => void = () => {};
vi.spyOn(api, call).mockImplementation(
(() =>
new Promise((_ok, no) => {
fail = no;
})) as never,
);
mount(abc, { lastMovePlaylistId: 2 });
await tick("Video A", "Video B");
await rowMenu("Video A", item);
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video B", "Video C"]));
await act(async () => fail(new HttpError(500, "boom")));
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video A", "Video B", "Video C"]));
await waitFor(() =>
expect(ticked()).toEqual(["playlists.selectRow:Video A", "playlists.selectRow:Video B"]),
);
});
it("ticks nothing on the list now on screen when a move from another one fails", async () => {
let fail: (e: unknown) => void = () => {};
const move = vi.spyOn(api, "moveManyToPlaylist").mockImplementation(
(() =>
new Promise((_ok, no) => {
fail = no;
})) as never,
);
const { rerender } = mount(
{ 1: [vid("A", 0), vid("B", 0)], 2: [vid("A", 0)], 3: [] },
{ lastMovePlaylistId: 3 },
);
await tick("Video A");
fireEvent.click(await button("playlists.moveToRecent"));
await waitFor(() => expect(move).toHaveBeenCalled());
h.selectedId = 2;
rerender();
await waitFor(() =>
expect(screen.queryByRole("button", { name: "card.play — Video B" })).toBeNull(),
);
await box("Video A");
await act(async () => fail(new HttpError(500, "boom")));
await waitFor(() => expect(toast().level).toBe("error"));
expect(((await box("Video A")) as HTMLInputElement).checked).toBe(false);
});
it("says out loud the count, and that the selection went", async () => {
mount(abc);
await box("Video A");
const status = () => document.querySelector(".sr-only[role=status]")!.textContent;
expect(status()).toBe("");
await tick("Video A");
await waitFor(() => expect(status()).toBe("playlists.selectedCount"));
await act(async () => press("Escape"));
await waitFor(() => expect(status()).toBe("playlists.selectionCleared"));
});
it("moves to the list picked in the band's menu, not the last one", async () => {
const move = vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A"]) as never);
mount({ ...abc, 3: [] }, { lastMovePlaylistId: 2 });
await tick("Video A");
fireEvent.click(await button("playlists.moveSelected"));
fireEvent.click(await menuRow("List 3"));
await waitFor(() => expect(move).toHaveBeenCalledWith(1, ["A"], 3));
});
it("moves to the list picked in a row's own menu", async () => {
const move = vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A"]) as never);
mount({ ...abc, 3: [] }, { lastMovePlaylistId: 2 });
await box("Video A");
await rowMenu("Video A", "playlists.moveTo");
fireEvent.click(await menuRow("List 3"));
await waitFor(() => expect(move).toHaveBeenCalledWith(1, ["A"], 3));
});
it("marks the target's cached list stale too, not only the one on screen", async () => {
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A"]) as never);
mount(abc, { lastMovePlaylistId: 2 });
await tick("Video A");
qc.setQueryData(qk.playlist(2), list(2, []));
fireEvent.click(await button("playlists.moveToRecent"));
await waitFor(() => expect(toast().action).toBeTruthy());
expect(qc.getQueryState(qk.playlist(2))?.isInvalidated).toBe(true);
});
it("says so when none of them was in the list any more, with no Undo to offer: a removal", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut() as never);
mount(abc);
await tick("Video A", "Video B");
fireEvent.click(await button("playlists.removeSelected"));
await waitFor(() => expect(toast().message).toEqual(msg("playlists.removeNoneLeft")));
expect(toast().action).toBeUndefined();
});
it("counts what really went, and its Undo brings back only those", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A", "C") as never);
const restore = vi
.spyOn(api, "restoreToPlaylist")
.mockResolvedValue({ ok: true, added: 2 } as never);
mount(abc);
await tick("Video A", "Video B", "Video C");
fireEvent.click(await button("playlists.removeSelected"));
await waitFor(() => expect(toast().action).toBeTruthy());
expect(toast().message).toEqual(msg("playlists.removedManyToast", { count: 2 }));
vi.spyOn(api, "playlist").mockResolvedValue(list(1, []) as never);
toast().action!.onClick();
await waitFor(() => expect(restore).toHaveBeenCalledWith(1, ["A", "C"], [0, 1]));
});
it.each([
[0, msg("playlists.undoNoneBack")],
[1, msg("playlists.undoSomeBack", { count: 1 })],
])("says what came back when a move's Undo brought %i of 2", async (added, said) => {
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A", "B"]) as never);
vi.spyOn(api, "undoPlaylistMove").mockResolvedValue({ ok: true, added } as never);
mount(abc, { lastMovePlaylistId: 2 });
await tick("Video A", "Video B");
fireEvent.click(await button("playlists.moveToRecent"));
await waitFor(() => expect(toast().action).toBeTruthy());
vi.spyOn(api, "playlist").mockResolvedValue(list(1, [vid("C", 0)]) as never);
toast().action!.onClick();
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "warning", message: said }),
),
);
});
it("says nothing more when a move's Undo brought them all", async () => {
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A"]) as never);
const undo = vi
.spyOn(api, "undoPlaylistMove")
.mockResolvedValue({ ok: true, added: 1 } as never);
mount(abc, { lastMovePlaylistId: 2 });
await tick("Video A");
fireEvent.click(await button("playlists.moveToRecent"));
await waitFor(() => expect(toast().action).toBeTruthy());
const toasts = h.notify.mock.calls.length;
vi.spyOn(api, "playlist").mockResolvedValue(list(1, [vid("B", 0), vid("C", 0)]) as never);
toast().action!.onClick();
await waitFor(() => expect(undo).toHaveBeenCalled());
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(h.notify.mock.calls.length).toBe(toasts);
});
it("does not call a list that failed to load no other list", async () => {
mount(
{ 1: [vid("A", 0)] },
{},
{ rail: async () => Promise.reject(new HttpError(500, "boom")) },
);
await tick("Video A");
fireEvent.click(await button("playlists.moveSelected"));
await waitFor(() =>
expect(screen.getByRole("menuitem", { hidden: true }).textContent).toContain(
"playlists.moveTargetsFailed",
),
);
});
it("does not call a list still loading no other list", async () => {
mount({ 1: [vid("A", 0)] }, {}, { rail: () => new Promise(() => {}) });
await tick("Video A");
fireEvent.click(await button("playlists.moveSelected"));
await waitFor(() =>
expect(screen.getByRole("menuitem", { hidden: true }).textContent).toContain(
"playlists.moveTargetsLoading",
),
);
});
it("leaves the focus alone when the band goes and it was elsewhere", async () => {
mount(abc);
await tick("Video A");
+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>
)}
@@ -1,4 +1,4 @@
import type { ReactNode, Ref } from "react";
import type { MouseEventHandler, ReactNode, Ref } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, FolderInput, History, ListRestart, Square, X } from "lucide-react";
import { Button } from "../../components/ui/Button";
@@ -8,7 +8,8 @@ import { Button } from "../../components/ui/Button";
* it leaves alone. With only hidden ones the actions stay in place, off, and the way out stays on.
* Below `xl` the buttons are glyphs named in their tooltips: in words the row wraps there, and the
* first tick would push the list from under the second tap. On a phone the count is its number and
* the undo pair stays out, for the same one row. */
* the undo pair stays out, for the same one row. The page says the count out loud: this band goes
* when the selection does, and a live region that leaves with its news announces nothing. */
export function SelectionBand({
ref,
count,
@@ -33,14 +34,14 @@ export function SelectionBand({
onRemove: () => void;
onClear: () => void;
/** The view's reset, which the row this one replaces offers. */
onClearView?: () => void;
onClearView?: MouseEventHandler<HTMLButtonElement>;
trailing: ReactNode;
}) {
const { t } = useTranslation();
const none = count === 0;
return (
<div ref={ref} className="flex items-center gap-1 mt-1 flex-wrap xl:gap-2">
<span role="status" className="text-xs whitespace-nowrap">
<span className="text-xs whitespace-nowrap">
<span className="sr-only font-medium sm:not-sr-only">
{t("playlists.selectedCount", { count })}
</span>
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { escapeHandled } from "../../lib/layerStack";
import {
isOverRail,
railDropAreaAttrs,
@@ -19,10 +20,10 @@ const runFrame = () => {
now.forEach((f) => f(0));
};
function rail() {
function rail({ short = false } = {}) {
const scroller = document.createElement("div");
scroller.style.overflowY = "auto";
Object.defineProperty(scroller, "scrollHeight", { value: 2000 });
Object.defineProperty(scroller, "scrollHeight", { value: short ? 400 : 2000 });
Object.defineProperty(scroller, "clientHeight", { value: 400 });
scroller.getBoundingClientRect = () =>
({ left: 0, right: 200, top: 100, bottom: 500, width: 200, height: 400 }) as DOMRect;
@@ -158,6 +159,47 @@ describe("railDrop", () => {
expect(scroller.scrollTop).toBeLessThan(down);
});
it("reads the row a wheel scrolled under a pointer that fired nothing", () => {
const { row } = rail();
const r1 = row(1);
const r2 = row(2);
let lying = r1;
under = () => [lying];
const { result } = renderHook(() => useRailDropTarget());
act(() => {
startRailDrag(100, 300);
});
expect(result.current).toBe(1);
lying = r2;
act(() => {
runFrame();
});
expect(result.current).toBe(2);
expect(stopRailDrag()).toBe(2);
});
it("keeps the rail it found when there is nothing to scroll, rather than look again each frame", () => {
rail({ short: true });
startRailDrag(100, 300);
const looked = vi.spyOn(window, "getComputedStyle");
runFrame();
runFrame();
expect(looked).not.toHaveBeenCalled();
looked.mockRestore();
});
it("spends the drag's Esc, and no other", () => {
const esc = () => {
const e = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
document.dispatchEvent(e);
return e;
};
startRailDrag(100, 300);
expect(escapeHandled(esc())).toBe(true);
stopRailDrag();
expect(escapeHandled(esc())).toBe(false);
});
it("finds the rail that opened only during the drag, as an unpinned one peeks", () => {
startRailDrag(100, 495);
runFrame();
+29 -32
View File
@@ -1,4 +1,6 @@
import { useSyncExternalStore } from "react";
import { markEscapeHandled } from "../../lib/layerStack";
import { scrollParent } from "../../lib/listPaging";
import { createStore } from "../../lib/store";
// Dragging a video onto a list in the rail. The rail and the page are separate React trees (the shell
// mounts them side by side, each lazily), so no drag context reaches both: the page's drag reads the
@@ -19,31 +21,21 @@ export const railDropAreaAttrs = { [RAIL_AREA]: "" };
const EDGE = 48;
const MAX_STEP = 12;
let over: number | null = null;
const over = createStore<number | null>(null);
let inRail = false;
let point: { x: number; y: number } | null = null;
let frame = 0;
let scroller: HTMLElement | null = null;
const listeners = new Set<() => void>();
function setOver(id: number | null) {
if (id === over) return;
over = id;
listeners.forEach((l) => l());
if (id !== over.get()) over.set(id);
}
const subscribe = (l: () => void) => {
listeners.add(l);
return () => {
listeners.delete(l);
};
};
/** The rail row under the drag right now. */
export const useRailDropTarget = (): number | null => useSyncExternalStore(subscribe, () => over);
export const useRailDropTarget = over.useValue;
/** The same, outside React. */
export const railDropTarget = (): number | null => over;
export const railDropTarget = over.get;
/** Whether the drag is over the rail at all, where the list's rows must not make way for it. */
export const isOverRail = (): boolean => inRail;
@@ -52,7 +44,7 @@ function readRail() {
if (!point) return;
let row: number | null = null;
let area = false;
// `elementsFromPoint`, because the drag image lies on top.
// `elementsFromPoint`: a toast or a tooltip lying over the rail takes no drop, the row under it does.
for (const el of document.elementsFromPoint(point.x, point.y)) {
const hit = el.closest(`[${RAIL_ROW}]`);
if (hit) row = Number(hit.getAttribute(RAIL_ROW));
@@ -64,22 +56,13 @@ function readRail() {
}
function railScroller(): HTMLElement | null {
for (
let el = document.querySelector<HTMLElement>(`[${RAIL_AREA}]`)?.parentElement;
el;
el = el.parentElement
) {
const { overflowY } = getComputedStyle(el);
if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight)
return el;
}
return null;
const area = document.querySelector(`[${RAIL_AREA}]`);
return area && scrollParent(area);
}
/** With 45 lists the row wanted is usually out of sight, and dnd-kit scrolls only what holds the
* dragged row: not the rail. */
function scrollStep() {
frame = requestAnimationFrame(scrollStep);
function scrollAtEdge() {
// Looked for again: an unpinned rail has no rows until the drag's own hover peeks it open.
if (!scroller?.isConnected) scroller = railScroller();
if (!point || !scroller) return;
@@ -93,8 +76,14 @@ function scrollStep() {
: fromBottom < EDGE
? MAX_STEP * (1 - Math.max(fromBottom, 0) / EDGE)
: 0;
if (step === 0) return;
scroller.scrollTop += step;
if (step !== 0) scroller.scrollTop += step;
}
function followStep() {
frame = requestAnimationFrame(followStep);
scrollAtEdge();
// Every frame, not on a move alone: a wheel scrolls another row under a pointer that fires nothing,
// and the drop would land on the row that scrolled away.
readRail();
}
@@ -103,20 +92,28 @@ const onPointerMove = (e: PointerEvent) => {
readRail();
};
// The drag's Esc is spent on the drag. dnd-kit's pointer sensor cancels on the document and prevents
// no default, so without this the list's Esc clears the selection on the same key press.
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") markEscapeHandled(e);
};
/** Follows the pointer from where the drag began, until `stopRailDrag`. */
export function startRailDrag(x: number, y: number): void {
stopRailDrag();
point = { x, y };
scroller = railScroller();
document.addEventListener("pointermove", onPointerMove, true);
frame = requestAnimationFrame(scrollStep);
window.addEventListener("keydown", onKeyDown, true);
frame = requestAnimationFrame(followStep);
readRail();
}
/** Ends the following, and answers the row the drag was over. Safe without a drag. */
export function stopRailDrag(): number | null {
const last = over;
const last = over.get();
document.removeEventListener("pointermove", onPointerMove, true);
window.removeEventListener("keydown", onKeyDown, true);
cancelAnimationFrame(frame);
point = null;
scroller = null;
+3 -2
View File
@@ -60,8 +60,9 @@ export default defineConfig({
port: 5173,
proxy,
// What an e2e run writes is no source. On Windows a file it is rewriting answers the watcher with
// EBUSY, which takes the whole dev server down; each run also reloaded every open page.
watch: { ignored: ["**/playwright-report/**", "**/test-results/**"] },
// EBUSY, which takes the whole dev server down; each run also reloaded every open page. (Its
// `test-results` is a default of vite's, and `ignored` adds to the defaults.)
watch: { ignored: ["**/playwright-report/**", "**/e2e/.auth/**"] },
},
build: { outDir: "dist", rollupOptions: { input: ["index.html", "watch.html"] } },
});