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.
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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<number> {
|
||||
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<string, unknown> };
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <body>", async () => {
|
||||
render(<Rows />);
|
||||
const leaving = screen.getByTestId("r1");
|
||||
followRemoval(screen.getByTestId("r1-action"));
|
||||
const list = leaving.parentElement!;
|
||||
list.removeChild(leaving);
|
||||
list.querySelector<HTMLElement>("[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(<Rows />);
|
||||
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);
|
||||
|
||||
@@ -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 <body>. By element, as a click on a button leaves the focus
|
||||
* elsewhere in Safari. */
|
||||
export function followRemoval(el: Element): void {
|
||||
const item = el.closest<HTMLElement>(`[${INDEX}]`);
|
||||
const list =
|
||||
item && lists.get(item.parentElement?.closest<HTMLElement>(`[${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 <body> and
|
||||
|
||||
@@ -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<string, any> })); // 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<typeof import("@dnd-kit/core")>();
|
||||
const { createElement } = await import("react");
|
||||
return {
|
||||
...actual,
|
||||
DndContext: (props: Record<string, unknown>) => {
|
||||
dnd.props = props;
|
||||
return createElement(actual.DndContext, props);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
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<number, Video[]> = { 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));
|
||||
|
||||
@@ -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) =>
|
||||
<ListPlus className="w-4 h-4" />
|
||||
);
|
||||
|
||||
/** 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] {
|
||||
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<Video>[];
|
||||
selected: boolean;
|
||||
/** Going along with the row being dragged. */
|
||||
carried: boolean;
|
||||
dragDisabled: boolean;
|
||||
/** A drag can only leave for another list: the view holds the order, and no key reaches the rail. */
|
||||
listDragOnly: boolean;
|
||||
dragHint: string;
|
||||
/** useContextMenu's trigger props. On the WHOLE row, not a handle: a right-click anywhere in the
|
||||
* row opens the menu, the way a native list does. The row's own buttons keep their left-click
|
||||
@@ -163,7 +201,7 @@ function Row({
|
||||
// No transition on the row being dragged: dnd-kit drives `transform` from the pointer, so
|
||||
// animating it makes the row trail the cursor. The others keep it for the reflow animation.
|
||||
transition: isDragging ? undefined : transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
opacity: isDragging || carried ? 0.6 : 1,
|
||||
}}
|
||||
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
|
||||
// the same lag by another route (150ms behind the pointer, on every row).
|
||||
@@ -181,13 +219,20 @@ function Row({
|
||||
>
|
||||
{col.key === GRIP_KEY ? (
|
||||
// The one cell the column defs can't render: the handle needs this row's sortable
|
||||
// attributes/listeners. Disabled (not hidden) under a view, so the affordance stays
|
||||
// put and can explain itself instead of silently doing nothing.
|
||||
// attributes/listeners. Disabled (not hidden) with nowhere to go, so the affordance
|
||||
// stays put and can explain itself instead of silently doing nothing.
|
||||
<DragHandle
|
||||
{...attributes}
|
||||
aria-describedby={listDragOnly ? undefined : attributes["aria-describedby"]}
|
||||
{...(dragDisabled ? {} : listeners)}
|
||||
unavailable={dragDisabled}
|
||||
label={dragDisabled ? dragHint : t("playlists.dragHandleOf", { title: video.title })}
|
||||
label={
|
||||
dragDisabled
|
||||
? dragHint
|
||||
: t(listDragOnly ? "playlists.dragHandleToList" : "playlists.dragHandleOf", {
|
||||
title: video.title,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
col.render(video)
|
||||
@@ -522,6 +567,8 @@ function PlaylistDetail() {
|
||||
// The grip was focusable and called itself "reorder", but only a pointer could move it.
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
// 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) => {
|
||||
@@ -540,8 +587,53 @@ function PlaylistDetail() {
|
||||
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[]>([]);
|
||||
const droppedOnRef = useRef<number | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
stopRailDrag();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
function onDragStart(e: DragStartEvent) {
|
||||
const id = String(e.active.id);
|
||||
const from = "clientX" in e.activatorEvent ? (e.activatorEvent as PointerEvent) : null;
|
||||
const rows =
|
||||
from && selection.isSelected(id) && selection.shownSelected.length > 1
|
||||
? selection.shownSelected
|
||||
: [id];
|
||||
carriedRef.current = rows;
|
||||
droppedOnRef.current = null;
|
||||
// No drag image for a key: it leaves nothing to carry, and the row itself walks the list.
|
||||
if (from) {
|
||||
setCarried(rows);
|
||||
startRailDrag(from.clientX, from.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function onDragCancel() {
|
||||
stopRailDrag();
|
||||
setCarried(null);
|
||||
}
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
const { active: a, over } = e;
|
||||
const droppedOn = stopRailDrag();
|
||||
setCarried(null);
|
||||
droppedOnRef.current = droppedOn;
|
||||
if (droppedOn != null) {
|
||||
const target = moveTargets.find((p) => p.id === droppedOn);
|
||||
const row = shown.find((v) => v.id === a.id);
|
||||
if (!target || !row) return;
|
||||
if (e.activatorEvent.target instanceof Element) followRemoval(e.activatorEvent.target);
|
||||
if (selection.isSelected(row.id)) moveSelected(target);
|
||||
else void moveRows([row], target);
|
||||
return;
|
||||
}
|
||||
// A collision reports nothing under a view or for many rows, so nothing is reordered there.
|
||||
if (!over || a.id === over.id || selectedId == null) return;
|
||||
const oldIndex = items.findIndex((v) => v.id === a.id);
|
||||
const newIndex = items.findIndex((v) => v.id === over.id);
|
||||
@@ -948,6 +1040,38 @@ function PlaylistDetail() {
|
||||
// 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);
|
||||
const storedOrderEditable = canEdit && !viewActive;
|
||||
// While the rows must stay put the others make no way: no row is "over" anything.
|
||||
const dragCollision: CollisionDetection = (args) =>
|
||||
storedOrderEditable && carriedRef.current.length <= 1 && !isOverRail()
|
||||
? closestCenter(pointerFirst(args))
|
||||
: [];
|
||||
const listNameOf = useLatest((id: number | null) => {
|
||||
const list = moveTargets.find((p) => p.id === id);
|
||||
return list ? plName(list) : "";
|
||||
});
|
||||
const dragA11y = useMemo(() => {
|
||||
const base = dndA11y.announcements;
|
||||
return {
|
||||
...dndA11y,
|
||||
announcements: {
|
||||
...base,
|
||||
onDragStart: (args: Parameters<typeof base.onDragStart>[0]) =>
|
||||
carriedRef.current.length > 1
|
||||
? t("playlists.dragPickedMany", { count: carriedRef.current.length })
|
||||
: base.onDragStart(args),
|
||||
onDragMove: () => {
|
||||
const id = railDropTarget();
|
||||
return id == null
|
||||
? undefined
|
||||
: t("playlists.dragOverList", { name: listNameOf.current(id) });
|
||||
},
|
||||
onDragEnd: (args: Parameters<typeof base.onDragEnd>[0]) =>
|
||||
droppedOnRef.current == null
|
||||
? base.onDragEnd(args)
|
||||
: t("playlists.dragDroppedOnList", { name: listNameOf.current(droppedOnRef.current) }),
|
||||
},
|
||||
};
|
||||
}, [dndA11y, t, listNameOf]);
|
||||
const ctxRow = rowMenu.target;
|
||||
const ctxItems: ActionMenuItem[] = !ctxRow
|
||||
? []
|
||||
@@ -1353,12 +1477,16 @@ function PlaylistDetail() {
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
sensors={viewActive ? pointerSensorOnly : sensors}
|
||||
collisionDetection={dragCollision}
|
||||
// Not the list, under a drag that is over the rail: it would run away beside it.
|
||||
autoScroll={{ canScroll: () => !isOverRail() }}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={onDragCancel}
|
||||
// dnd-kit's defaults are hardcoded English. They were harmless while only a pointer could
|
||||
// drag; now that the keyboard can, they ARE the instructions for the feature.
|
||||
accessibility={dndA11y}
|
||||
accessibility={dragA11y}
|
||||
>
|
||||
{/* Not clipped, so the header's filter popovers can overflow the table box. A container,
|
||||
so a narrow column hides by the table's width, not the window's (`narrowHidden`). */}
|
||||
@@ -1426,7 +1554,9 @@ function PlaylistDetail() {
|
||||
video={v}
|
||||
columns={tableColumns}
|
||||
selected={selection.isSelected(v.id)}
|
||||
dragDisabled={!canEdit || viewActive}
|
||||
carried={!!carried && carried.includes(v.id)}
|
||||
dragDisabled={!canEdit || (viewActive && !moveTargets.length)}
|
||||
listDragOnly={viewActive}
|
||||
dragHint={dragHint}
|
||||
menuProps={rowMenu.triggerProps(v)}
|
||||
/>
|
||||
@@ -1440,6 +1570,28 @@ function PlaylistDetail() {
|
||||
</div>
|
||||
)}
|
||||
</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(
|
||||
<DragOverlay
|
||||
dropAnimation={null}
|
||||
zIndex={100}
|
||||
className="pointer-events-none"
|
||||
modifiers={[beneathPointer]}
|
||||
>
|
||||
{carried && (
|
||||
<div
|
||||
className={`${SURFACE.popover} w-fit max-w-xs truncate px-3 py-2 text-sm font-medium`}
|
||||
>
|
||||
{carried.length > 1
|
||||
? t("playlists.itemCount", { count: carried.length })
|
||||
: shown.find((v) => v.id === carried[0])?.title}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>,
|
||||
document.body,
|
||||
)}
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The rail's rows as targets of a drag that starts in another tree: which ones are, and the one lit up.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, cleanup, act } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
vi.mock("../../lib/useRail", () => ({
|
||||
useRail: () => ({
|
||||
layout: { order: ["manage", "list"], collapsed: {}, hidden: {} },
|
||||
setLayout: () => {},
|
||||
collapsed: false,
|
||||
onToggleCollapse: () => {},
|
||||
}),
|
||||
}));
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
|
||||
});
|
||||
vi.mock("./playlistsContext", () => ({
|
||||
usePlaylists: () => ({ search: "", selectedId: 1 }),
|
||||
usePlaylistsActions: () => ({ setSelectedId: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../../lib/useMe", () => ({ useMe: () => ({ can_write: true }) }));
|
||||
vi.mock("../../features/comfort/comfortPin", () => ({
|
||||
useComfortPin: () => ({ item: () => [] }),
|
||||
pinPlaylist: (id: number) => id,
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({
|
||||
useChoice: () => async () => null,
|
||||
useConfirm: () => async () => false,
|
||||
}));
|
||||
vi.mock("../../components/ui/FilterRail", () => ({
|
||||
default: ({ items }: { items: { id: string; body: ReactNode }[] }) => (
|
||||
<>
|
||||
{items.map((i) => (
|
||||
<div key={i.id}>{i.body}</div>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
import PlaylistsRail from "./PlaylistsRail";
|
||||
import { api, type Playlist } from "../../lib/api";
|
||||
import { startRailDrag, stopRailDrag } from "./railDrop";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
Element.prototype.scrollIntoView ??= () => {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopRailDrag();
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const pl = (id: number, name: string) =>
|
||||
({
|
||||
id,
|
||||
name,
|
||||
kind: "user",
|
||||
source: "local",
|
||||
yt_playlist_id: null,
|
||||
item_count: 1,
|
||||
total_duration_seconds: 0,
|
||||
cover_thumbnail: null,
|
||||
dirty: false,
|
||||
}) as unknown as Playlist;
|
||||
|
||||
async function mount() {
|
||||
vi.spyOn(api, "playlists").mockResolvedValue([pl(1, "Open"), pl(2, "Other")] as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<PlaylistsRail />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const wrap = async (name: string) =>
|
||||
(await screen.findByText(name)).closest("[class*='group']") as HTMLElement;
|
||||
return { open: await wrap("Open"), other: await wrap("Other") };
|
||||
}
|
||||
|
||||
describe("PlaylistsRail — a row as a drop target", () => {
|
||||
it("offers every row but the open list", async () => {
|
||||
const { open, other } = await mount();
|
||||
expect(other.getAttribute("data-playlist-drop")).toBe("2");
|
||||
expect(open.hasAttribute("data-playlist-drop")).toBe(false);
|
||||
expect(open.closest("[data-playlist-drop-area]")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lights up the row under the drag, and only while it is", async () => {
|
||||
const { open, other } = await mount();
|
||||
document.elementsFromPoint = () => [other];
|
||||
expect(other.className).not.toContain("ring-2");
|
||||
act(() => {
|
||||
startRailDrag(0, 0);
|
||||
});
|
||||
expect(other.className).toContain("ring-2");
|
||||
expect(open.className).not.toContain("ring-2");
|
||||
act(() => {
|
||||
stopRailDrag();
|
||||
});
|
||||
expect(other.className).not.toContain("ring-2");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import clsx from "clsx";
|
||||
import { ListVideo, Pin, Plus, RefreshCw } from "lucide-react";
|
||||
import { YouTubeIcon } from "../../lib/brandIcons";
|
||||
import { api, type Playlist } from "../../lib/api";
|
||||
@@ -32,6 +33,7 @@ import { Input } from "../../components/ui/Input";
|
||||
import { MoreButton } from "../../components/ui/MoreButton";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
import { useRail } from "../../lib/useRail";
|
||||
import { railDropAreaAttrs, railDropAttrs, useRailDropTarget } from "./railDrop";
|
||||
|
||||
type PlSort = {
|
||||
key: "custom" | "name" | "count" | "duration" | "recent";
|
||||
@@ -55,6 +57,7 @@ export default function PlaylistsRail() {
|
||||
// The name filter + the selected playlist live in PlaylistsProvider (shared with the header search
|
||||
// box and the Playlists detail pane); this rail owns the list query + its own controls.
|
||||
const { search, selectedId } = usePlaylists();
|
||||
const dropTarget = useRailDropTarget();
|
||||
const { setSelectedId } = usePlaylistsActions();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
@@ -282,7 +285,7 @@ export default function PlaylistsRail() {
|
||||
{playlists.length > 0 && visiblePlaylists.length === 0 && (
|
||||
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1" {...railDropAreaAttrs}>
|
||||
{visiblePlaylists.map((pl) =>
|
||||
renamingId === pl.id ? (
|
||||
// The row becomes its own rename field. A <button> can't contain an <input>, so the
|
||||
@@ -328,7 +331,16 @@ export default function PlaylistsRail() {
|
||||
) : (
|
||||
// `group`: the row's "…" shows on its hover or focus (`.reveal`), and always on touch.
|
||||
// A sibling of the row button, not inside it — a button cannot hold a button.
|
||||
<div key={pl.id} className="group relative" {...railMenu.triggerProps(pl)}>
|
||||
// The open list is no target: the video is already there.
|
||||
<div
|
||||
key={pl.id}
|
||||
className={clsx(
|
||||
"group relative rounded-lg",
|
||||
dropTarget === pl.id && "bg-accent/15 ring-2 ring-accent ring-inset",
|
||||
)}
|
||||
{...railMenu.triggerProps(pl)}
|
||||
{...(pl.id === selectedId ? {} : railDropAttrs(pl.id))}
|
||||
>
|
||||
<Pressable
|
||||
selectable
|
||||
ref={(el) => {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isOverRail,
|
||||
railDropAreaAttrs,
|
||||
railDropAttrs,
|
||||
startRailDrag,
|
||||
stopRailDrag,
|
||||
useRailDropTarget,
|
||||
} from "./railDrop";
|
||||
|
||||
// jsdom lays nothing out: what lies under a point is whatever the test says, by row id.
|
||||
let under: (x: number, y: number) => Element[] = () => [];
|
||||
let frames: FrameRequestCallback[] = [];
|
||||
const runFrame = () => {
|
||||
const now = frames;
|
||||
frames = [];
|
||||
now.forEach((f) => f(0));
|
||||
};
|
||||
|
||||
function rail() {
|
||||
const scroller = document.createElement("div");
|
||||
scroller.style.overflowY = "auto";
|
||||
Object.defineProperty(scroller, "scrollHeight", { value: 2000 });
|
||||
Object.defineProperty(scroller, "clientHeight", { value: 400 });
|
||||
scroller.getBoundingClientRect = () =>
|
||||
({ left: 0, right: 200, top: 100, bottom: 500, width: 200, height: 400 }) as DOMRect;
|
||||
const area = document.createElement("div");
|
||||
for (const [k, v] of Object.entries(railDropAreaAttrs)) area.setAttribute(k, v);
|
||||
const row = (id: number) => {
|
||||
const el = document.createElement("div");
|
||||
for (const [k, v] of Object.entries(railDropAttrs(id))) el.setAttribute(k, String(v));
|
||||
area.append(el);
|
||||
return el;
|
||||
};
|
||||
const open = document.createElement("div");
|
||||
area.append(open);
|
||||
scroller.append(area);
|
||||
document.body.append(scroller);
|
||||
return { scroller, area, open, row };
|
||||
}
|
||||
|
||||
const moveTo = (x: number, y: number) =>
|
||||
document.dispatchEvent(new MouseEvent("pointermove", { clientX: x, clientY: y }));
|
||||
|
||||
beforeEach(() => {
|
||||
under = () => [];
|
||||
frames = [];
|
||||
document.elementsFromPoint = (x, y) => under(x, y);
|
||||
vi.stubGlobal("requestAnimationFrame", (f: FrameRequestCallback) => frames.push(f));
|
||||
vi.stubGlobal("cancelAnimationFrame", () => {
|
||||
frames = [];
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopRailDrag();
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("railDrop", () => {
|
||||
it("says which row the pointer is over, through the drag image lying on top", () => {
|
||||
const { row } = rail();
|
||||
const image = document.createElement("div");
|
||||
const r7 = row(7);
|
||||
under = () => [image, r7];
|
||||
const { result } = renderHook(() => useRailDropTarget());
|
||||
expect(result.current).toBeNull();
|
||||
act(() => {
|
||||
startRailDrag(0, 0);
|
||||
});
|
||||
expect(result.current).toBe(7);
|
||||
under = () => [];
|
||||
act(() => {
|
||||
moveTo(600, 300);
|
||||
});
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("finds a row from its child, as the pointer lands on the row's cover", () => {
|
||||
const { row } = rail();
|
||||
const child = document.createElement("span");
|
||||
row(3).append(child);
|
||||
under = () => [child];
|
||||
startRailDrag(0, 0);
|
||||
expect(stopRailDrag()).toBe(3);
|
||||
});
|
||||
|
||||
it("answers the last row on stop, and leaves no highlight or listener behind", () => {
|
||||
const { row } = rail();
|
||||
const r5 = row(5);
|
||||
under = () => [r5];
|
||||
const { result } = renderHook(() => useRailDropTarget());
|
||||
act(() => {
|
||||
startRailDrag(0, 0);
|
||||
});
|
||||
let dropped: number | null = null;
|
||||
act(() => {
|
||||
dropped = stopRailDrag();
|
||||
});
|
||||
expect(dropped).toBe(5);
|
||||
expect(result.current).toBeNull();
|
||||
act(() => {
|
||||
moveTo(10, 10);
|
||||
});
|
||||
expect(result.current).toBeNull();
|
||||
expect(stopRailDrag()).toBeNull();
|
||||
});
|
||||
|
||||
it("tells the rail apart from a row: the open list is in the rail, though no target", () => {
|
||||
const { open } = rail();
|
||||
under = () => [open];
|
||||
startRailDrag(0, 0);
|
||||
expect(isOverRail()).toBe(true);
|
||||
expect(stopRailDrag()).toBeNull();
|
||||
expect(isOverRail()).toBe(false);
|
||||
});
|
||||
|
||||
it("is not over the rail anywhere else on the page", () => {
|
||||
rail();
|
||||
const elsewhere = document.createElement("div");
|
||||
document.body.append(elsewhere);
|
||||
under = () => [elsewhere];
|
||||
startRailDrag(0, 0);
|
||||
expect(isOverRail()).toBe(false);
|
||||
});
|
||||
|
||||
it("scrolls the rail while the pointer holds at its edge, and reads the row that came under it", () => {
|
||||
const { scroller, row } = rail();
|
||||
const r1 = row(1);
|
||||
const r2 = row(2);
|
||||
under = () => (scroller.scrollTop > 0 ? [r2] : [r1]);
|
||||
const { result } = renderHook(() => useRailDropTarget());
|
||||
act(() => {
|
||||
startRailDrag(100, 300);
|
||||
});
|
||||
act(() => {
|
||||
runFrame();
|
||||
});
|
||||
expect(scroller.scrollTop).toBe(0);
|
||||
act(() => {
|
||||
moveTo(100, 495);
|
||||
});
|
||||
act(() => {
|
||||
runFrame();
|
||||
});
|
||||
expect(scroller.scrollTop).toBeGreaterThan(0);
|
||||
expect(result.current).toBe(2);
|
||||
const down = scroller.scrollTop;
|
||||
act(() => {
|
||||
moveTo(100, 105);
|
||||
});
|
||||
act(() => {
|
||||
runFrame();
|
||||
});
|
||||
expect(scroller.scrollTop).toBeLessThan(down);
|
||||
});
|
||||
|
||||
it("finds the rail that opened only during the drag, as an unpinned one peeks", () => {
|
||||
startRailDrag(100, 495);
|
||||
runFrame();
|
||||
const { scroller } = rail();
|
||||
runFrame();
|
||||
expect(scroller.scrollTop).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not scroll the rail for a pointer beside it, at the same height", () => {
|
||||
const { scroller } = rail();
|
||||
startRailDrag(600, 495);
|
||||
runFrame();
|
||||
expect(scroller.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("stops scrolling on stop", () => {
|
||||
const { scroller } = rail();
|
||||
startRailDrag(100, 495);
|
||||
stopRailDrag();
|
||||
expect(frames).toHaveLength(0);
|
||||
runFrame();
|
||||
expect(scroller.scrollTop).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// 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
|
||||
// rail by what lies under the pointer, and this says the answer to the rail's rows.
|
||||
//
|
||||
// The pointer is followed here rather than worked out from dnd-kit's `delta`: that one includes what
|
||||
// the list has scrolled during the drag, which is no distance on the screen.
|
||||
|
||||
const RAIL_ROW = "data-playlist-drop";
|
||||
const RAIL_AREA = "data-playlist-drop-area";
|
||||
|
||||
/** Marks a rail row as somewhere a drag can land. */
|
||||
export const railDropAttrs = (id: number) => ({ [RAIL_ROW]: id });
|
||||
/** Marks the rail's rows as a whole: over them a drag is out of the list, target or not. */
|
||||
export const railDropAreaAttrs = { [RAIL_AREA]: "" };
|
||||
|
||||
/** How near the rail's edge the pointer scrolls it, and how fast at the very edge, per frame. */
|
||||
const EDGE = 48;
|
||||
const MAX_STEP = 12;
|
||||
|
||||
let over: 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());
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
/** The same, outside React. */
|
||||
export const railDropTarget = (): number | null => over;
|
||||
|
||||
/** 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;
|
||||
|
||||
function readRail() {
|
||||
if (!point) return;
|
||||
let row: number | null = null;
|
||||
let area = false;
|
||||
// `elementsFromPoint`, because the drag image lies on top.
|
||||
for (const el of document.elementsFromPoint(point.x, point.y)) {
|
||||
const hit = el.closest(`[${RAIL_ROW}]`);
|
||||
if (hit) row = Number(hit.getAttribute(RAIL_ROW));
|
||||
if (el.closest(`[${RAIL_AREA}]`)) area = true;
|
||||
if (row != null) break;
|
||||
}
|
||||
inRail = area;
|
||||
setOver(row);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
// 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;
|
||||
const box = scroller.getBoundingClientRect();
|
||||
if (point.x < box.left || point.x > box.right) return;
|
||||
const fromTop = point.y - box.top;
|
||||
const fromBottom = box.bottom - point.y;
|
||||
const step =
|
||||
fromTop < EDGE
|
||||
? -MAX_STEP * (1 - Math.max(fromTop, 0) / EDGE)
|
||||
: fromBottom < EDGE
|
||||
? MAX_STEP * (1 - Math.max(fromBottom, 0) / EDGE)
|
||||
: 0;
|
||||
if (step === 0) return;
|
||||
scroller.scrollTop += step;
|
||||
readRail();
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
point = { x: e.clientX, y: e.clientY };
|
||||
readRail();
|
||||
};
|
||||
|
||||
/** 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);
|
||||
readRail();
|
||||
}
|
||||
|
||||
/** Ends the following, and answers the row the drag was over. Safe without a drag. */
|
||||
export function stopRailDrag(): number | null {
|
||||
const last = over;
|
||||
document.removeEventListener("pointermove", onPointerMove, true);
|
||||
cancelAnimationFrame(frame);
|
||||
point = null;
|
||||
scroller = null;
|
||||
inRail = false;
|
||||
setOver(null);
|
||||
return last;
|
||||
}
|
||||
Reference in New Issue
Block a user