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
@@ -357,7 +357,7 @@ describe("Playlists — a change the server refused is taken back on screen", ()
it("puts back the order when saving a reorder failed", async () => {
vi.spyOn(api, "reorderPlaylist").mockRejectedValue(new HttpError(500, "boom"));
mount({ 1: [vid("V", 0), vid("W", 0)] });
await rowMenu("Video V", "playlists.orderDown");
await rowMenu("Video V", "common.order.down");
await waitFor(() => expect(api.reorderPlaylist).toHaveBeenCalled());
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
@@ -378,11 +378,11 @@ describe("Playlists — a change the server refused is taken back on screen", ()
// From here no refetch ever lands, so the cache is whatever the page itself put there.
vi.mocked(api.playlist).mockReturnValue(new Promise(() => {}) as never);
await rowMenu("Video V", "playlists.orderDown");
await rowMenu("Video V", "common.order.down");
await waitFor(() => expect(reorder).toHaveBeenCalledTimes(1));
expect(rowsOnScreen()).toEqual(["Video W", "Video V", "Video X"]);
await rowMenu("Video X", "playlists.orderUp");
await rowMenu("Video X", "common.order.up");
await waitFor(() => expect(reorder).toHaveBeenCalledTimes(2));
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(rowsOnScreen()).toEqual(["Video W", "Video V", "Video X"]);