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
+13 -37
View File
@@ -18,19 +18,7 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ArrowDown,
ArrowDownToLine,
ArrowUp,
ArrowUpToLine,
Check,
FolderInput,
History,
ListPlus,
Play,
X,
Youtube,
} from "lucide-react";
import { Check, FolderInput, History, ListPlus, Play, X, Youtube } from "lucide-react";
import { api, type Playlist, type PlaylistDetail, type Video } from "../lib/api";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
@@ -60,6 +48,7 @@ import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableC
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
import { DragHandle } from "./ui/DragHandle";
import { useOrderItems } from "./ui/useOrderItems";
import { Input } from "./ui/Input";
import { CheckRow } from "./ui/NativeControls";
import { useReturnFocus } from "./ui/useReturnFocus";
@@ -561,13 +550,11 @@ export default function Playlists() {
// `order.set` (undoable + persisted), not a direct API call. Gated by the same rule as the drag
// handle: a view (sort/group/filter) is not the stored order, so moving "up" inside one would
// write something the user never saw.
function moveInOrder(video: Video, to: "top" | "up" | "down" | "bottom") {
const orderItems = useOrderItems();
function moveInOrder(video: Video, to: number) {
const from = items.findIndex((v) => v.id === video.id);
if (from < 0) return;
const target =
to === "top" ? 0 : to === "bottom" ? items.length - 1 : to === "up" ? from - 1 : from + 1;
if (target === from || target < 0 || target >= items.length) return;
order.set(arrayMove(items, from, target)); // onApply persists
if (from < 0 || to === from) return;
order.set(arrayMove(items, from, to)); // onApply persists
}
async function saveRename() {
@@ -687,24 +674,13 @@ export default function Playlists() {
note: t("playlists.moveNoTargets"),
onSelect: () => {},
},
...(
[
["top", t("playlists.orderTop"), <ArrowUpToLine className="w-4 h-4" />],
["up", t("playlists.orderUp"), <ArrowUp className="w-4 h-4" />],
["down", t("playlists.orderDown"), <ArrowDown className="w-4 h-4" />],
["bottom", t("playlists.orderBottom"), <ArrowDownToLine className="w-4 h-4" />],
] as const
).map(([to, label, icon], i) => ({
id: `order-${to}`,
label,
icon,
separatorBefore: i === 0,
// Same gate as the drag handle. The reason goes under the FIRST of the four rows
// only — one explanation for the group, not the same line four times.
disabled: !storedOrderEditable,
note: i === 0 ? dragHint : undefined,
onSelect: () => moveInOrder(ctxRow, to),
})),
// Same gate as the drag handle; the reason goes under the first of the four rows.
...orderItems(
items.findIndex((v) => v.id === ctxRow.id),
items.length,
(to) => moveInOrder(ctxRow, to),
storedOrderEditable ? undefined : { reason: dragHint },
),
{
id: "remove",
label: t("playlists.removeItem"),