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
+47 -24
View File
@@ -41,6 +41,7 @@ import { useCopyItems } from "./copyItems";
import { usePlexCardActions } from "./usePlexCardActions";
import { Pressable } from "./ui/Pressable";
import { DragHandle } from "./ui/DragHandle";
import { useOrderItems } from "./ui/useOrderItems";
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
import {
sameListCollision,
@@ -55,9 +56,10 @@ import { PageHeading } from "./ui/Headings";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
// list. Reorder is drag & drop (a whole show-group moves as one block; episodes reorder within their
// show). Two layouts — Accordion (A) and Tree (C) — are a per-account preference. Rendered as a
// PlexBrowse subview; "Play all" opens the player with the whole ordered list as its queue.
// list. Reorder is drag & drop, or a row menu's Top / Up / Down / Bottom (a whole show-group moves as
// one block; episodes reorder within their show). Two layouts — Accordion (A) and Tree (C) — are a
// per-account preference. Rendered as a PlexBrowse subview; "Play all" opens the player with the
// whole ordered list as its queue.
//
// E28 C1: every row — a title, a show group, a season — has ONE menu, opened by its "…" (shown on
// hover or focus, always on touch), a right-click, a long-press or Shift+F10. Removing needs no
@@ -505,34 +507,40 @@ export default function PlexPlaylistView({
return { title: eps[ep]?.title ?? "", pos: ep + 1, total: eps.length };
});
// A move, by drag or by a row menu's Top / Up / Down / Bottom: a whole block (a movie, a show)
// among the blocks, an episode only within its own show.
function moveBlock(id: string, to: number) {
const from = blocks.findIndex((b) => b.id === id);
if (from < 0 || to < 0 || to >= blocks.length || from === to) return;
commit(flatten(arrayMove(blocks, from, to)));
}
function moveEpisode(id: string, to: number) {
const b = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === id));
if (!b || b.kind !== "show") return;
const from = b.items.findIndex((it) => it.id === id);
if (to < 0 || to >= b.items.length || from === to) return;
commit(
flatten(
blocks.map((bl) => (bl.id === b.id ? { ...bl, items: arrayMove(b.items, from, to) } : bl)),
),
);
}
function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
const aId = String(active.id);
const oId = String(over.id);
const blockIds = new Set(blocks.map((b) => b.id));
// `over` is always a sibling of `active` (sameListCollision): a block over a block, an episode
// over an episode of the same show. The checks below only refuse a stale id.
if (blockIds.has(aId)) {
// Moving a whole block (movie or show) among the blocks.
const from = blocks.findIndex((b) => b.id === aId);
const to = blocks.findIndex((b) => b.id === oId);
if (from < 0 || to < 0) return;
commit(flatten(arrayMove(blocks, from, to)));
} else {
// Moving an episode — within its own show block.
const b = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === aId));
if (!b || b.kind !== "show" || !b.items.some((it) => it.id === oId)) return;
const from = b.items.findIndex((it) => it.id === aId);
const to = b.items.findIndex((it) => it.id === oId);
commit(
flatten(
blocks.map((bl) =>
bl.id === b.id ? { ...bl, items: arrayMove(b.items, from, to) } : bl,
),
),
);
// over an episode of the same show. The lookups only refuse a stale id.
const toBlock = blocks.findIndex((b) => b.id === oId);
if (blocks.some((b) => b.id === aId)) {
if (toBlock >= 0) moveBlock(aId, toBlock);
return;
}
const show = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === aId));
const to = show?.kind === "show" ? show.items.findIndex((it) => it.id === oId) : -1;
if (to >= 0) moveEpisode(aId, to);
}
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
@@ -586,12 +594,26 @@ export default function PlexPlaylistView({
},
]
: [];
// The non-drag way (WCAG 2.5.7): what the row's grip drags, its menu moves — a block among the
// blocks, an episode within its show. Off while a write is in flight, like the grip.
const orderItems = useOrderItems();
const off = busy ? {} : undefined;
const orderRows = (target: Exclude<MenuTarget, { kind: "season" }>): ActionMenuItem[] => {
const id = target.kind === "item" ? target.item.id : target.block.id;
const at = blocks.findIndex((b) => b.id === id);
if (at >= 0) return orderItems(at, blocks.length, (to) => moveBlock(id, to), off);
const show = blocks.find((b) => b.kind === "show" && b.items.some((it) => it.id === id));
if (show?.kind !== "show") return [];
const ep = show.items.findIndex((it) => it.id === id);
return orderItems(ep, show.items.length, (to) => moveEpisode(id, to), off);
};
const menuTarget = rowMenu.target;
const menuItems: ActionMenuItem[] = !menuTarget
? []
: menuTarget.kind === "item"
? [
...titleActions.items(menuTarget.item),
...orderRows(menuTarget),
removeRow(menuTarget, t("plex.playlist.removeFromPlaylist")),
]
: menuTarget.kind === "show"
@@ -601,6 +623,7 @@ export default function PlexPlaylistView({
...it,
separatorBefore: i === 0,
})),
...orderRows(menuTarget),
removeRow(menuTarget, t("plex.playlist.removeShow")),
]
: [