From d79cb528fd7e93049847bc6cee3008b2005a1c5f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 2 Nov 2026 00:38:39 +0100 Subject: [PATCH] feat(playlists): drag rows onto a list in the rail A row's grip carries it, or every selected row with a selected one, onto another list, under a sort or a filter too, and the toast undoes. The rail and the page are separate React trees, so the page reads the rail by what lies under the pointer. An unpinned rail peeks by the drag's own hover and scrolls at its edges. Many rows dropped inside the list stay; a key still moves its own row. --- docs/DESIGN.md | 6 + frontend/e2e/playlists.spec.ts | 259 ++++++++++++++ frontend/src/i18n/locales/en/playlists.json | 5 + frontend/src/i18n/locales/hu/playlists.json | 5 + frontend/src/lib/listNav.select.dom.test.tsx | 23 +- frontend/src/lib/listNav.ts | 10 + .../modules/playlists/Playlists.dom.test.tsx | 316 ++++++++++++++++++ frontend/src/modules/playlists/Playlists.tsx | 172 +++++++++- .../playlists/PlaylistsRail.drop.dom.test.tsx | 119 +++++++ .../src/modules/playlists/PlaylistsRail.tsx | 16 +- .../modules/playlists/railDrop.dom.test.ts | 184 ++++++++++ frontend/src/modules/playlists/railDrop.ts | 126 +++++++ 12 files changed, 1228 insertions(+), 13 deletions(-) create mode 100644 frontend/e2e/playlists.spec.ts create mode 100644 frontend/src/modules/playlists/PlaylistsRail.drop.dom.test.tsx create mode 100644 frontend/src/modules/playlists/railDrop.dom.test.ts create mode 100644 frontend/src/modules/playlists/railDrop.ts diff --git a/docs/DESIGN.md b/docs/DESIGN.md index a08097fe..5d986bac 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -547,6 +547,11 @@ of a full-width panel and nothing on the right. Below `xl` the band's buttons are glyphs named in their tooltips, and a filter's hidden count is "+N" with its sentence in the tooltip. The row the band replaces keeps its height, invisible, so a tick moves no row. On a phone the count is its number (the words read out, and in the tooltip) and the undo pair stays out. + A row's grip also carries it onto a list in the rail, under a sort or a filter too (the grip then says + so, and no key lifts it); a selected row carries every selected one, named on the drag image below the + pointer. The target row lights up (`ring-accent`); the open list is none; an unpinned rail peeks by the + drag's own hover and scrolls at its edges. Dropped inside the list, one row takes its new place and many + stay. A key never leaves the list and moves its own row: the band and the row's menu are the other way. - **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected. [WCAG 2.3.3] @@ -850,3 +855,4 @@ of a full-width panel and nothing on the right. | 2026-11-01 | Grip on a phone (owner A) | Below a 24rem table a playlist row has no grip: the title keeps its width, and the row menu keeps the moves. | | 2026-11-01 | Selection band on a tablet (owner A) | Below `xl` the band is glyphs on one row, the names in tooltip and aria-label, as on the channel header; the replaced row stays in the same grid cell, invisible, so the first tick moves no row under the second tap. | | 2026-11-01 | Playlist heading on a narrow window (owner A) | The list's name had no width left: below `xl` Unsynced is its dot (the word read out, the hint in the tooltip); on a phone the cover is gone and Play all is its glyph, named in the tooltip. | +| 2026-11-02 | Dragging onto a list (owner D5 a, D6 a) | The grip carries a row, or every selected row with a selected one, onto a list in the rail; the toast undoes. Many dropped inside the list stay; a coarse pointer's drawer never peeks, so its rail is a target only when pinned. | diff --git a/frontend/e2e/playlists.spec.ts b/frontend/e2e/playlists.spec.ts new file mode 100644 index 00000000..57b8417d --- /dev/null +++ b/frontend/e2e/playlists.spec.ts @@ -0,0 +1,259 @@ +import { test, expect, type Locator, type Page } from "@playwright/test"; +import { openApp } from "./helpers"; + +// A video dragged onto a list in the rail, with a real pointer: the rail and the page are separate +// React trees, and jsdom lays out nothing to drag across. The browser projects share one account, so +// each test holds lists of its own, named for it, and takes them away after. + +interface Listed { + id: number; + items: { id: string; title: string }[]; +} + +const made: number[] = []; + +async function newList(page: Page, name: string, videos: string[] = []): Promise { + const res = await page.request.post("/api/playlists", { data: { name } }); + const { id } = (await res.json()) as { id: number }; + made.push(id); + for (const video_id of videos) { + const added = await page.request.post(`/api/playlists/${String(id)}/items`, { + data: { video_id }, + }); + expect(added.ok()).toBe(true); + } + return id; +} + +const contents = async (page: Page, id: number) => + ((await (await page.request.get(`/api/playlists/${String(id)}`)).json()) as Listed).items.map( + (v) => v.id, + ); + +async function setUp(page: Page, name: string, project: string) { + await pinOnTouch(page, project); + const feed = (await (await page.request.get("/api/feed?limit=3")).json()) as { + items: { id: string; title: string }[]; + }; + expect(feed.items.length).toBe(3); + const videos = feed.items; + const from = await newList( + page, + `${name} from`, + videos.map((v) => v.id), + ); + const to = await newList(page, `${name} to`); + await openApp(page, `/?page=playlists&pl=${String(from)}`); + await expect(page.getByRole("heading", { name: `${name} from` })).toBeVisible(); + await expect(page.locator("tbody tr")).toHaveCount(3); + return { videos, from, to, target: page.locator(`[data-playlist-drop="${String(to)}"]`) }; +} + +const centre = async (el: Locator) => { + const box = await el.boundingBox(); + if (!box) throw new Error("nothing to point at"); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +}; + +/** Pick the row up by its grip and carry it to a point, the button still held. */ +async function carry(page: Page, grip: Locator, to: { x: number; y: number }) { + const from = await centre(grip); + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(from.x + 8, from.y + 8, { steps: 3 }); + await page.mouse.move(to.x, to.y, { steps: 12 }); +} + +/** The same onto the rail. An unpinned one is a tab until the drag's own hover peeks it open. */ +async function carryToRail(page: Page, grip: Locator, target: Locator) { + if (await target.isVisible()) { + await carry(page, grip, await centre(target)); + return; + } + const tab = page.getByRole("button", { name: "Pin Playlists open" }); + await carry(page, grip, await centre(tab)); + await expect(target).toBeVisible(); + await page.mouse.move(...(Object.values(await centre(target)) as [number, number]), { steps: 6 }); +} + +/** What dnd-kit tells a screen reader: said once the drag has taken the step in, so a drop after it + * is the one a person makes, not one that beats the row to its new place. */ +const said = (page: Page, text: RegExp) => + expect(page.locator('[id^="DndLiveRegion"]').filter({ hasText: text })).toBeAttached(); + +/** A coarse pointer's rail never peeks, a drawer instead: on the tablet it is pinned, for these. */ +async function pinOnTouch(page: Page, project: string) { + if (project !== "ipad") return; + await page.route("**/api/me", async (route) => { + const res = await route.fetch(); + const me = (await res.json()) as { preferences?: Record }; + await route.fulfill({ + response: res, + json: { ...me, preferences: { ...me.preferences, playlistsCollapsed: false } }, + }); + }); +} + +test.describe("dragging onto a list", { tag: "@playlists" }, () => { + test.afterEach(async ({ page }) => { + await page.mouse.up(); + for (const id of made.splice(0)) await page.request.delete(`/api/playlists/${String(id)}`); + }); + + test("a row leaves for the list it is dropped on, lit up on the way, and Undo brings it back", async ({ + page, + }, info) => { + const { videos, from, to, target } = await setUp( + page, + `drag one ${info.project.name}`, + info.project.name, + ); + const grip = page.getByRole("button", { name: `Reorder “${videos[1]!.title}”`, exact: false }); + await carryToRail(page, grip, target); + await expect(target).toHaveClass(/ring-2/); + await page.mouse.up(); + await expect(target).not.toHaveClass(/ring-2/); + await expect(page.locator("tbody tr")).toHaveCount(2); + // The grip that held the focus has left: the keyboard keeps its place in the list. + await expect + .poll(() => page.evaluate(() => document.activeElement?.closest("tbody") != null)) + .toBe(true); + await expect.poll(() => contents(page, to)).toEqual([videos[1]!.id]); + expect(await contents(page, from)).toEqual([videos[0]!.id, 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)); + expect(await contents(page, to)).toEqual([]); + }); + + test("a selected row carries the selected ones, named on the drag image", async ({ + page, + }, info) => { + const { videos, from, to, target } = await setUp( + page, + `drag many ${info.project.name}`, + info.project.name, + ); + await page.getByRole("checkbox", { name: videos[0]!.title, exact: false }).click(); + await page.getByRole("checkbox", { name: videos[2]!.title, exact: false }).click(); + const grip = page.getByRole("button", { name: `Reorder “${videos[2]!.title}”`, exact: false }); + await carryToRail(page, grip, target); + const image = page.getByText("2 videos", { exact: true }); + await expect(image).toBeVisible(); + // Below the pointer, so it leaves the list's name to read. + const { y } = await centre(target); + expect((await image.boundingBox())!.y).toBeGreaterThan(y); + await page.mouse.up(); + await expect.poll(() => contents(page, to)).toEqual([videos[0]!.id, videos[2]!.id]); + expect(await contents(page, from)).toEqual([videos[1]!.id]); + await expect(page.getByRole("checkbox", { name: "Select all" })).not.toBeChecked(); + }); + + test("many rows dropped inside the list stay as they were", async ({ page }, info) => { + const { videos, from } = await setUp( + page, + `drag inside ${info.project.name}`, + info.project.name, + ); + await page.getByRole("checkbox", { name: videos[0]!.title, exact: false }).click(); + await page.getByRole("checkbox", { name: videos[1]!.title, exact: false }).click(); + const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false }); + await carry(page, grip, await centre(page.locator("tbody tr").nth(2))); + await page.mouse.up(); + await expect(page.locator("tbody tr")).toHaveCount(3); + expect(await contents(page, from)).toEqual(videos.map((v) => v.id)); + }); + + test("one row dropped inside the list takes the new place", async ({ page }, info) => { + const { videos, from } = await setUp( + page, + `drag order ${info.project.name}`, + info.project.name, + ); + const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false }); + await carry(page, grip, await centre(page.locator("tbody tr").nth(2))); + await said(page, /position 3/); + await page.mouse.up(); + await expect + .poll(() => contents(page, from)) + .toEqual([videos[1]!.id, videos[2]!.id, videos[0]!.id]); + }); + + test("Escape puts the drag down, with nothing moved and nothing lit", async ({ page }, info) => { + const { videos, from, to, target } = await setUp( + page, + `drag cancel ${info.project.name}`, + info.project.name, + ); + const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false }); + await carryToRail(page, grip, target); + await expect(target).toHaveClass(/ring-2/); + await page.keyboard.press("Escape"); + await expect(target).not.toHaveClass(/ring-2/); + await page.mouse.up(); + expect(await contents(page, from)).toEqual(videos.map((v) => v.id)); + expect(await contents(page, to)).toEqual([]); + }); + + test("a key still reorders a row, alone", async ({ page }, info) => { + const { videos, from } = await setUp(page, `drag key ${info.project.name}`, info.project.name); + await page.getByRole("checkbox", { name: videos[0]!.title, exact: false }).click(); + await page.getByRole("checkbox", { name: videos[2]!.title, exact: false }).click(); + await page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false }).focus(); + await page.keyboard.press("Space"); + await said(page, /picked up/i); + await page.keyboard.press("ArrowDown"); + await said(page, /position 2/); + await page.keyboard.press("Space"); + await expect + .poll(() => contents(page, from)) + .toEqual([videos[1]!.id, videos[0]!.id, videos[2]!.id]); + }); + + test("under a filter a row still goes to another list, and none is reordered", async ({ + page, + }, info) => { + const { videos, from, to, target } = await setUp( + page, + `drag filter ${info.project.name}`, + info.project.name, + ); + await page.getByRole("columnheader", { name: "Title" }).getByRole("button").last().click(); + await page.getByPlaceholder("Title").fill(videos[1]!.title); + await page.keyboard.press("Escape"); + await expect(page.locator("tbody tr")).toHaveCount(1); + const grip = page.getByRole("button", { + name: `Drag “${videos[1]!.title}” onto a list`, + exact: false, + }); + await carryToRail(page, grip, target); + await page.mouse.up(); + await expect.poll(() => contents(page, to)).toEqual([videos[1]!.id]); + expect(await contents(page, from)).toEqual([videos[0]!.id, videos[2]!.id]); + }); + + test("the rail scrolls while the drag holds at its edge", async ({ page }, info) => { + const name = `drag scroll ${info.project.name}`; + for (let i = 0; i < 12; i++) await newList(page, `${name} ${String(i)}`); + await page.setViewportSize({ width: 1280, height: 520 }); + const { videos } = await setUp(page, name, info.project.name); + const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false }); + const area = page.locator("[data-playlist-drop-area]"); + await carryToRail(page, grip, area.locator("[data-playlist-drop]").first()); + const scroller = () => + area.evaluate((el) => { + for (let p = el.parentElement; p; p = p.parentElement) { + const { overflowY } = getComputedStyle(p); + if ((overflowY === "auto" || overflowY === "scroll") && p.scrollHeight > p.clientHeight) { + const { left, right, bottom } = p.getBoundingClientRect(); + return { top: p.scrollTop, x: (left + right) / 2, bottom }; + } + } + return { top: -1, x: 0, bottom: 0 }; + }); + const before = await scroller(); + expect(before.top).toBeGreaterThanOrEqual(0); + await page.mouse.move(before.x, before.bottom - 8, { steps: 4 }); + await expect.poll(async () => (await scroller()).top).toBeGreaterThan(before.top); + await page.keyboard.press("Escape"); + }); +}); diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 4ed5ee13..11c3a4a0 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -122,6 +122,11 @@ "undoMoveFailed": "Couldn't move the video back", "undoMoveManyFailed": "Couldn't move the videos back", "undoRemoveManyFailed": "Couldn't put the videos back", + "dragHandleToList": "Drag “{{title}}” onto a list", + "dragPickedMany_one": "{{count}} video picked up", + "dragPickedMany_other": "{{count}} videos picked up", + "dragOverList": "Over “{{name}}”", + "dragDroppedOnList": "Dropped on “{{name}}”", "dragBlockedSort": "Reordering by hand is off while a column sort or grouping is applied.", "dragBlockedFilter": "Reordering by hand is off while a filter is applied.", "clearView": "Reset view", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index 93f6c261..0e737f07 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -122,6 +122,11 @@ "undoMoveFailed": "Nem sikerült visszahelyezni a videót", "undoMoveManyFailed": "Nem sikerült visszahelyezni a videókat", "undoRemoveManyFailed": "Nem sikerült visszatenni a videókat", + "dragHandleToList": "„{{title}}” húzása egy listára", + "dragPickedMany_one": "{{count}} videó felvéve", + "dragPickedMany_other": "{{count}} videó felvéve", + "dragOverList": "A(z) „{{name}}” lista fölött", + "dragDroppedOnList": "Letéve a(z) „{{name}}” listára", "dragBlockedSort": "A kézi átrendezés ki van kapcsolva, amíg oszloprendezés vagy csoportosítás van érvényben.", "dragBlockedFilter": "A kézi átrendezés ki van kapcsolva, amíg szűrő van érvényben.", "clearView": "Nézet visszaállítása", diff --git a/frontend/src/lib/listNav.select.dom.test.tsx b/frontend/src/lib/listNav.select.dom.test.tsx index 388ca702..6960d972 100644 --- a/frontend/src/lib/listNav.select.dom.test.tsx +++ b/frontend/src/lib/listNav.select.dom.test.tsx @@ -5,7 +5,7 @@ import { useRef } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; -import { navItemAttrs, useListNav, type NavSelection } from "./listNav"; +import { followRemoval, navItemAttrs, useListNav, type NavSelection } from "./listNav"; import { useActiveScopes } from "./keymap"; afterEach(cleanup); @@ -115,6 +115,27 @@ describe("useListNav · selecting", () => { expect(idle.clear).toHaveBeenCalledTimes(1); }); + it("takes the slot of an item that left by other hands, the focus on ", async () => { + render(); + const leaving = screen.getByTestId("r1"); + followRemoval(screen.getByTestId("r1-action")); + const list = leaving.parentElement!; + list.removeChild(leaving); + list.querySelector("[data-testid=r2]")!.setAttribute("data-nav-index", "1"); + await waitFor(() => expect(document.activeElement).toBe(screen.getByTestId("r2"))); + }); + + it("leaves the focus where it is when something else took it meanwhile", async () => { + render(); + const leaving = screen.getByTestId("r1"); + const other = screen.getByTestId("r0-input"); + other.focus(); + followRemoval(screen.getByTestId("r1-action")); + leaving.remove(); + await new Promise((r) => setTimeout(r, 60)); + expect(document.activeElement).toBe(other); + }); + it("has Delete go to the selection first, wherever the focus is", () => { const s = selection(); s.removeSelected.mockReturnValue(true); diff --git a/frontend/src/lib/listNav.ts b/frontend/src/lib/listNav.ts index 289bfaf8..00d5bde2 100644 --- a/frontend/src/lib/listNav.ts +++ b/frontend/src/lib/listNav.ts @@ -493,6 +493,16 @@ function refocusSlot(list: NavList, index: number, removed: HTMLElement): void { requestAnimationFrame(wait); } +/** The item holding `el` is leaving its list by other hands than Delete (a drag out of it): the focus + * takes its slot all the same, not . By element, as a click on a button leaves the focus + * elsewhere in Safari. */ +export function followRemoval(el: Element): void { + const item = el.closest(`[${INDEX}]`); + const list = + item && lists.get(item.parentElement?.closest(`[${LIST}]`) as HTMLElement); + if (item && list) refocusSlot(list, indexOf(item), item); +} + // ── The item a view was left from ──────────────────────────────────────────────────────────────── // Opening something that switches the page or the view — a Plex title from Home's rail, a channel from // the Channels table — unmounts the list the keyboard was on, so Back used to hand focus to and diff --git a/frontend/src/modules/playlists/Playlists.dom.test.tsx b/frontend/src/modules/playlists/Playlists.dom.test.tsx index d2aabb4d..808994fc 100644 --- a/frontend/src/modules/playlists/Playlists.dom.test.tsx +++ b/frontend/src/modules/playlists/Playlists.dom.test.tsx @@ -28,6 +28,21 @@ const h = vi.hoisted(() => ({ }, })); +// The real drag context, with its props in reach: jsdom lays nothing out for a real pointer to drag +// across (e2e/playlists.spec.ts does that). +const dnd = vi.hoisted(() => ({ props: null as unknown as Record })); // eslint-disable-line @typescript-eslint/no-explicit-any -- what dnd-kit passes its handlers, which these tests build by hand +vi.mock("@dnd-kit/core", async (importOriginal) => { + const actual = await importOriginal(); + const { createElement } = await import("react"); + return { + ...actual, + DndContext: (props: Record) => { + dnd.props = props; + return createElement(actual.DndContext, props); + }, + }; +}); + vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { @@ -88,6 +103,7 @@ vi.mock("../../features/player/PlayerModal", async () => { }; }); +import { railDropAreaAttrs, railDropAttrs, railDropTarget, stopRailDrag } from "./railDrop"; import Playlists from "./Playlists"; import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot"; import { PageMenuSlot, type PageMenuOpener } from "../../components/ui/pageMenuSlot"; @@ -1171,6 +1187,306 @@ describe("Playlists — the list's name keeps its room on a narrow window", () = }); }); +describe("Playlists — dragging rows", () => { + beforeEach(() => void vi.spyOn(api, "savePrefs").mockResolvedValue({} as never)); + afterEach(() => { + stopRailDrag(); + document.querySelectorAll("[data-test-rail]").forEach((el) => el.remove()); + }); + + const abc: Record = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)], 2: [] }; + const box = (title: string) => + screen.findByRole("checkbox", { name: `playlists.selectRow:${title}` }); + const tick = async (...titles: string[]) => { + for (const title of titles) fireEvent.click(await box(title)); + }; + const moved = (ids: string[]) => ({ + ok: true, + from_playlist_id: 1, + to_playlist_id: 2, + moved: ids, + already_there: [], + }); + const pointer = { clientX: 10, clientY: 10 } as unknown as Event; + const key = new KeyboardEvent("keydown", { key: " " }); + const pickUp = (id: string, activatorEvent: Event = pointer) => + act(() => { + dnd.props.onDragStart({ active: { id }, activatorEvent }); + }); + const dropOn = (id: string, over: string | null = null) => + act(() => { + dnd.props.onDragEnd({ active: { id }, over: over && { id: over }, activatorEvent: pointer }); + }); + const aRow = (id: string) => screen.findByText(`Video ${id}`); + /** The pointer lying over this rail row of another tree. */ + const overRail = (id: number | null) => { + const area = document.createElement("div"); + area.dataset.testRail = ""; + for (const [k, v] of Object.entries(railDropAreaAttrs)) area.setAttribute(k, v); + const row = document.createElement("div"); + if (id != null) + for (const [k, v] of Object.entries(railDropAttrs(id))) row.setAttribute(k, String(v)); + area.append(row); + document.body.append(area); + document.elementsFromPoint = () => [row]; + }; + const overNothing = () => { + document.elementsFromPoint = () => []; + }; + const collide = () => + dnd.props.collisionDetection({ + active: { id: "A" }, + collisionRect: { top: 0, left: 0, width: 10, height: 10, bottom: 10, right: 10 }, + droppableRects: new Map([ + ["B", { top: 0, left: 0, width: 10, height: 10, bottom: 10, right: 10 }], + ]), + droppableContainers: [{ id: "B" }], + pointerCoordinates: null, + }); + + it("moves a row dropped on another list, and leaves the ticks on the others alone", async () => { + const move = vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["B"]) as never); + mount(abc); + await tick("Video A"); + overRail(2); + pickUp("B"); + dropOn("B"); + await waitFor(() => expect(move).toHaveBeenCalled()); + expect(move.mock.calls[0]![1]).toEqual(["B"]); + expect(move.mock.calls[0]![2]).toBe(2); + expect(((await box("Video A")) as HTMLInputElement).checked).toBe(true); + }); + + it("carries every selected row with a selected one, and clears the selection", async () => { + const move = vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A", "C"]) as never); + mount(abc); + await tick("Video C", "Video A"); + overRail(2); + pickUp("C"); + dropOn("C"); + await waitFor(() => expect(move).toHaveBeenCalled()); + expect([...move.mock.calls[0]![1]].sort()).toEqual(["A", "C"]); + expect(screen.queryByText("playlists.selectedCount")).toBeNull(); + }); + + it("dims the rows going along while the drag lasts", async () => { + mount(abc); + await tick("Video A", "Video B"); + overNothing(); + const rowOf = async (id: string) => (await aRow(id)).closest("tr")!; + pickUp("A"); + expect((await rowOf("B")).style.opacity).toBe("0.6"); + expect((await rowOf("C")).style.opacity).toBe("1"); + act(() => { + dnd.props.onDragCancel(); + }); + expect((await rowOf("B")).style.opacity).toBe("1"); + }); + + it("does nothing with many rows dropped inside the list", async () => { + const order = vi.spyOn(api, "reorderPlaylist").mockResolvedValue({} as never); + const move = vi.spyOn(api, "moveManyToPlaylist"); + mount(abc); + await tick("Video A", "Video B"); + overNothing(); + pickUp("A"); + expect(collide()).toEqual([]); + dropOn("A"); + await new Promise((r) => setTimeout(r, 20)); + expect(order).not.toHaveBeenCalled(); + expect(move).not.toHaveBeenCalled(); + expect(((await box("Video A")) as HTMLInputElement).checked).toBe(true); + }); + + it("reorders one row inside the list, a selected one alone too", async () => { + const order = vi.spyOn(api, "reorderPlaylist").mockResolvedValue({} as never); + mount(abc); + await tick("Video A"); + overNothing(); + pickUp("A"); + expect(collide().length).toBeGreaterThan(0); + dropOn("A", "C"); + await waitFor(() => expect(order).toHaveBeenCalled()); + expect(order.mock.calls[0]![1]).toEqual(["B", "C", "A"]); + }); + + it("lands a pointer drag on the row under the pointer, whatever the drag image's box", async () => { + mount(abc); + await aRow("A"); + overNothing(); + pickUp("A"); + const box = (top: number) => ({ + top, + left: 0, + width: 10, + height: 50, + bottom: top + 50, + right: 10, + }); + const hit = dnd.props.collisionDetection({ + active: { id: "A" }, + collisionRect: { top: 60, left: 0, width: 10, height: 10, bottom: 70, right: 10 }, + droppableRects: new Map([ + ["B", box(50)], + ["C", box(100)], + ]), + droppableContainers: [{ id: "B" }, { id: "C" }], + pointerCoordinates: { x: 5, y: 125 }, + }); + expect(hit[0].id).toBe("C"); + }); + + it("makes the rows give no way while the drag is over the rail, even over the open list", async () => { + mount(abc); + await aRow("A"); + overRail(null); + pickUp("A"); + expect(collide()).toEqual([]); + }); + + it("moves nothing dropped on the rail's open list, though it is in the rail", async () => { + const order = vi.spyOn(api, "reorderPlaylist").mockResolvedValue({} as never); + const move = vi.spyOn(api, "moveManyToPlaylist"); + mount(abc); + await aRow("A"); + overRail(null); + pickUp("A"); + dropOn("A"); + await new Promise((r) => setTimeout(r, 20)); + expect(move).not.toHaveBeenCalled(); + expect(order).not.toHaveBeenCalled(); + }); + + it("moves nothing for a row of a list that has gone by the drop", async () => { + const move = vi.spyOn(api, "moveManyToPlaylist"); + mount(abc); + await aRow("A"); + overRail(99); + pickUp("A"); + dropOn("A"); + await new Promise((r) => setTimeout(r, 20)); + expect(move).not.toHaveBeenCalled(); + }); + + it("keeps out of the rail when a key lifted the row, which also carries only itself", async () => { + mount(abc); + await tick("Video A", "Video B"); + overRail(2); + pickUp("A", key); + expect(railDropTarget()).toBeNull(); + // The row walks by itself, with no drag image and none going along. + expect((await aRow("B")).closest("tr")!.style.opacity).toBe("1"); + expect(collide().length).toBeGreaterThan(0); + }); + + it("leaves the rail on a cancel, moving nothing", async () => { + const move = vi.spyOn(api, "moveManyToPlaylist"); + mount(abc); + await aRow("A"); + overRail(2); + pickUp("A"); + expect(railDropTarget()).toBe(2); + act(() => { + dnd.props.onDragCancel(); + }); + expect(railDropTarget()).toBeNull(); + expect(move).not.toHaveBeenCalled(); + }); + + describe("under a sort or a filter", () => { + const filter = async (value: string) => { + fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!); + fireEvent.change(await screen.findByPlaceholderText("playlists.colTitle"), { + target: { value }, + }); + await screen.findByRole("button", { name: "playlists.clearView" }); + }; + + it("still lets a row go to another list, and says so on the grip", async () => { + const move = vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A"]) as never); + mount(abc); + await aRow("A"); + await filter("Video"); + const grip = await screen.findByRole("button", { + name: "playlists.dragHandleToList:Video A", + }); + expect(grip.getAttribute("aria-disabled")).not.toBe("true"); + overRail(2); + pickUp("A"); + dropOn("A"); + await waitFor(() => expect(move).toHaveBeenCalled()); + expect(move.mock.calls[0]![1]).toEqual(["A"]); + }); + + it("reorders nothing in the list, and offers no key to lift a row with", async () => { + const order = vi.spyOn(api, "reorderPlaylist").mockResolvedValue({} as never); + mount(abc); + await aRow("A"); + expect(dnd.props.sensors).toHaveLength(2); + await filter("Video"); + expect(dnd.props.sensors).toHaveLength(1); + overNothing(); + pickUp("A"); + expect(collide()).toEqual([]); + dropOn("A"); + await new Promise((r) => setTimeout(r, 20)); + expect(order).not.toHaveBeenCalled(); + }); + + it("explains the grip with no other list to go to", async () => { + mount({ 1: abc[1]! }); + await aRow("A"); + await filter("Video"); + const grips = await screen.findAllByRole("button", { name: "playlists.dragBlockedFilter" }); + expect(grips).toHaveLength(3); + }); + }); + + describe("what a screen reader hears", () => { + const say = () => dnd.props.accessibility.announcements; + + it("names how many are picked up, and the list under the drag", async () => { + mount(abc); + await tick("Video A", "Video B"); + overRail(2); + pickUp("A"); + expect(say().onDragStart({ active: { id: "A" } })).toBe("playlists.dragPickedMany"); + expect(say().onDragMove({ active: { id: "A" } })).toBe("playlists.dragOverList"); + }); + + it("says nothing about a list while the drag is off the rail", async () => { + mount(abc); + await aRow("A"); + overNothing(); + pickUp("A"); + expect(say().onDragMove({ active: { id: "A" } })).toBeUndefined(); + }); + + it("says where it landed, not that it was cancelled", async () => { + vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A"]) as never); + mount(abc); + await aRow("A"); + overRail(2); + pickUp("A"); + dropOn("A"); + expect(say().onDragEnd({ active: { id: "A" }, over: null })).toBe( + "playlists.dragDroppedOnList", + ); + }); + + it("says it was cancelled for a drop that moved nothing", async () => { + mount(abc); + await aRow("A"); + overNothing(); + pickUp("A"); + dropOn("A"); + expect(say().onDragEnd({ active: { id: "A" }, over: null })).toContain( + "common.dnd.cancelled", + ); + }); + }); +}); + describe("Playlists — acting on the selected rows", () => { // A move remembers its target on the server, and an answer from nowhere would be the last toast. beforeEach(() => void vi.spyOn(api, "savePrefs").mockResolvedValue({} as never)); diff --git a/frontend/src/modules/playlists/Playlists.tsx b/frontend/src/modules/playlists/Playlists.tsx index cd75360f..e4085b09 100644 --- a/frontend/src/modules/playlists/Playlists.tsx +++ b/frontend/src/modules/playlists/Playlists.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { useLatest, useStableCallback } from "../../lib/useLatest"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -19,9 +20,13 @@ import { KeyboardSensor, PointerSensor, closestCenter, + DragOverlay, useSensor, useSensors, + type CollisionDetection, type DragEndEvent, + type DragStartEvent, + type Modifier, } from "@dnd-kit/core"; import { SortableContext, @@ -29,7 +34,7 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; +import { CSS, getEventCoordinates } from "@dnd-kit/utilities"; import { Check, Eraser, FolderInput, History, ListPlus, Play, X } from "lucide-react"; import { YouTubeIcon } from "../../lib/brandIcons"; import { @@ -73,7 +78,7 @@ import { usePlaylistActions } from "./usePlaylistActions"; import { usePlaylists } from "./playlistsContext"; import { railDefaultId } from "./playlistsInit"; import { usePref } from "../../lib/usePref"; -import { navItemAttrs } from "../../lib/listNav"; +import { followRemoval, navItemAttrs } from "../../lib/listNav"; import { ColumnHead } from "../../components/ui/tableHeader"; import { alignClass } from "../../components/ui/tableAlign"; import { @@ -86,6 +91,7 @@ import { import { GRIP_KEY, playlistColumns, selectColumn } from "./playlistColumns"; import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y"; import { DragHandle } from "../../components/ui/DragHandle"; +import { SURFACE } from "../../components/ui/surfaces"; import { RowSelectBox } from "../../components/ui/RowSelectBox"; import { useOrderItems } from "../../components/ui/useOrderItems"; import { Input } from "../../components/ui/Input"; @@ -98,6 +104,7 @@ import { useCopyItems } from "../../components/ui/copyItems"; import RemoteImage from "../../components/ui/RemoteImage"; import PlaylistsHeader from "./PlaylistsHeader"; import { SelectionBand } from "./SelectionBand"; +import { isOverRail, railDropTarget, startRailDrag, stopRailDrag } from "./railDrop"; import clsx from "clsx"; // The playlist detail is a HYBRID table: real sortable/filterable column headers over a @@ -129,12 +136,39 @@ const moveTargetIcon = (pl: Playlist) => ); +/** 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[0]): Parameters[0] { + const at = args.pointerCoordinates; + if (!at) return args; + const { width, height } = args.collisionRect; + const left = at.x - width / 2; + const top = at.y - height / 2; + return { + ...args, + collisionRect: { width, height, left, top, right: left + width, bottom: top + height }, + }; +} + +/** The drag image hangs below the pointer: on it, it covered the name of the list it was over. */ +const beneathPointer: Modifier = ({ activatorEvent, draggingNodeRect, transform }) => { + const at = activatorEvent && getEventCoordinates(activatorEvent); + if (!at || !draggingNodeRect) return transform; + return { + ...transform, + x: transform.x + at.x - draggingNodeRect.left + 12, + y: transform.y + at.y - draggingNodeRect.top + 16, + }; +}; + function Row({ video, index, columns, selected, + carried, dragDisabled, + listDragOnly, dragHint, menuProps, }: { @@ -143,7 +177,11 @@ function Row({ index: number; columns: Column