feat(design): reorder without a drag everywhere a grip drags (WCAG 2.5.7, E28 S3e4a)

One rule, two forms: useOrderItems (Top / Up / Down / Bottom rows of a row
menu) and OrderButtons (four buttons with the place, or a compact Up / Down
pair). A move that would go nowhere is shown off.

- YouTube playlist rows: the four moves now come from useOrderItems.
- Plex playlist rows: a block among the blocks, an episode within its show,
  off while a write is in flight; drag and menu share moveBlock/moveEpisode.
- Saved views editor: the selected view's four buttons (owner: A), and the
  grip's keyboard pick-up (its instructions promised one it did not have).
- Side panel groups in edit mode: an Up / Down pair (owner: A); a group the
  stored order does not hold yet moves too; the name wraps there.
- The grip's touch-action: none is now test-held.
This commit is contained in:
2026-09-26 18:14:28 +02:00
parent 7c2b22bafa
commit 4d0abc61d5
22 changed files with 512 additions and 91 deletions
@@ -114,7 +114,7 @@ describe("PlexPlaylistView · a row's menu", () => {
expect(moreOf("The Show")).toBeTruthy();
});
it("lists the title's own actions, then the removal", async () => {
it("lists the title's own actions, its moves, then the removal", async () => {
const info = vi.fn();
mount([movie("1")], info);
await screen.findByText("Movie 1");
@@ -126,6 +126,10 @@ describe("PlexPlaylistView · a row's menu", () => {
"plex.info.title",
"common.copy.title",
"home.pin.add",
"common.order.top",
"common.order.up",
"common.order.down",
"common.order.bottom",
"plex.playlist.removeFromPlaylist",
]);
expect(moreOf("Movie 1").getAttribute("aria-expanded")).toBe("true");
@@ -165,6 +169,10 @@ describe("PlexPlaylistView · a row's menu", () => {
expect(menuRows()).toEqual([
"plex.playlist.playShow",
"common.copy.title",
"common.order.top",
"common.order.up",
"common.order.down",
"common.order.bottom",
"plex.playlist.removeShow",
]);
fireEvent.click(
@@ -198,6 +206,43 @@ describe("PlexPlaylistView · a row's menu", () => {
});
});
const moveRow = (label: string) =>
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!;
describe("PlexPlaylistView · reordering without a drag (WCAG 2.5.7)", () => {
it("moves a title among the blocks from its menu, the moves that go nowhere held off", async () => {
mount([movie("1"), ep("e1", 1, 1), movie("2")]);
await screen.findByText("Movie 1");
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue({} as never);
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
expect((moveRow("common.order.top") as HTMLButtonElement).disabled).toBe(true);
expect((moveRow("common.order.up") as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(moveRow("common.order.bottom"));
// The show moves as one block: Movie 1 lands after Movie 2, the episode stays with its show.
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["e1", "2", "1"]));
});
it("moves a whole show from its group's menu", async () => {
mount([movie("1"), ep("e1", 1, 1), ep("e2", 1, 2)]);
await screen.findByText("The Show");
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue({} as never);
await pick(() => fireEvent.click(moreOf("The Show")), "common.order.up");
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["e1", "e2", "1"]));
});
it("moves an episode only within its own show", async () => {
mount([movie("1"), ep("e1", 1, 1), ep("e2", 1, 2), ep("e3", 1, 3)]);
fireEvent.click(await screen.findByText("The Show")); // open the group
await screen.findByText("Ep e3");
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue({} as never);
await openMenu(() => fireEvent.click(moreOf("Ep e3")));
// Last of three in its show: down is off, though a movie sits above the show in the list.
expect((moveRow("common.order.down") as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(moveRow("common.order.top"));
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["1", "e3", "e1", "e2"]));
});
});
describe("PlexPlaylistView · the keyboard", () => {
it("walks the rows with j and takes out the walked one on Delete", async () => {
mount([movie("1"), movie("2"), movie("3")]);