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