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
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
//
// A side panel's groups in edit mode move without a drag too (WCAG 2.5.7): each carries an Up / Down
// pair, named by its group, and a move lands in the stored order.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
import PanelGroups, { type PanelItem } from "./PanelGroups";
import type { PanelLayout } from "../../lib/panelLayout";
afterEach(cleanup);
const items: PanelItem[] = ["a", "b", "c"].map((id) => ({
id,
title: `Group ${id}`,
body: <p>{id} body</p>,
}));
const layout = (order: string[]): PanelLayout => ({ order, collapsed: {}, hidden: {} });
function mount(order: string[], editing = true) {
const setLayout = vi.fn();
render(
<PanelGroups items={items} layout={layout(order)} setLayout={setLayout} editing={editing} />,
);
const pair = (title: string) => screen.getByRole("group", { name: new RegExp(title) });
const btn = (title: string, name: string) =>
within(pair(title)).getByRole("button", { name }) as HTMLButtonElement;
return { setLayout, btn };
}
describe("PanelGroups — reordering without a drag", () => {
it("moves a group up or down, the ends held off", () => {
const { setLayout, btn } = mount(["a", "b", "c"]);
expect(btn("Group a", "common.order.up").disabled).toBe(true);
expect(btn("Group c", "common.order.down").disabled).toBe(true);
fireEvent.click(btn("Group a", "common.order.down"));
expect(setLayout).toHaveBeenLastCalledWith(expect.objectContaining({ order: ["b", "a", "c"] }));
fireEvent.click(btn("Group c", "common.order.up"));
expect(setLayout).toHaveBeenLastCalledWith(expect.objectContaining({ order: ["a", "c", "b"] }));
});
it("moves a group the stored order does not hold yet", () => {
// "c" became available after the layout was saved: it renders last, and still moves.
const { setLayout, btn } = mount(["a", "b"]);
fireEvent.click(btn("Group c", "common.order.up"));
expect(setLayout).toHaveBeenLastCalledWith(expect.objectContaining({ order: ["a", "c", "b"] }));
});
it("offers no move outside edit mode", () => {
mount(["a", "b", "c"], false);
expect(screen.queryByRole("button", { name: "common.order.up" })).toBeNull();
});
});