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
@@ -184,3 +184,50 @@ describe("PersonalViewsModal — S7 editable detail pane", () => {
await waitFor(() => expect(h.rename).toHaveBeenCalledWith(5, "Documentaries"));
});
});
describe("PersonalViewsModal — reordering without a drag (WCAG 2.5.7)", () => {
const view = (id: number, name: string, position: number) => ({
id,
name,
filters: VIEW_FILTERS,
position,
is_default: false,
});
it("moves the selected view with its order buttons, the moves that go nowhere held off", async () => {
CTX.reorder.mockClear();
CTX.views = [view(5, "Docs", 0), view(6, "Music", 1), view(7, "Tech", 2)];
mount();
const group = await screen.findByRole("group", { name: /common\.order\.label/ });
const btn = (name: string) =>
Array.from(group.querySelectorAll("button")).find(
(b) => b.getAttribute("aria-label") === name,
)!;
expect(btn("common.order.top").disabled).toBe(true);
expect(btn("common.order.up").disabled).toBe(true);
fireEvent.click(btn("common.order.down"));
expect(CTX.reorder).toHaveBeenLastCalledWith([6, 5, 7]);
fireEvent.click(btn("common.order.bottom"));
expect(CTX.reorder).toHaveBeenLastCalledWith([6, 7, 5]);
// A view picked in the list moves from ITS place, not the first one's.
fireEvent.click(screen.getByRole("button", { name: "Tech" }));
await waitFor(() => expect(btn("common.order.up").disabled).toBe(false));
fireEvent.click(btn("common.order.up"));
expect(CTX.reorder).toHaveBeenLastCalledWith([5, 7, 6]);
});
it("has no order buttons for a single view", async () => {
mount();
await screen.findByDisplayValue("Docs");
expect(screen.queryByRole("group", { name: /common\.order\.label/ })).toBeNull();
});
it("picks a view up from the keyboard, as its grip's instructions say", async () => {
CTX.views = [view(5, "Docs", 0), view(6, "Music", 1)];
mount();
const grip = (await screen.findAllByRole("button", { name: "views.reorder" }))[0]!;
grip.focus();
fireEvent.keyDown(grip, { code: "Space", key: " " });
await waitFor(() => expect(document.body.textContent).toContain("common.dnd.pickedUp"));
});
});