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"));
});
});
+28 -6
View File
@@ -4,12 +4,18 @@ import { BookmarkPlus, Check, Play, Save, Star, Trash2 } from "lucide-react";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
import Modal from "./ui/Modal";
@@ -22,15 +28,17 @@ import { useUndoable } from "../lib/useUndoable";
import UndoToolbar from "./ui/UndoToolbar";
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
import { DragHandle } from "./ui/DragHandle";
import { OrderButtons } from "./ui/OrderButtons";
import { Pressable } from "./ui/Pressable";
import { DismissibleChip } from "./ui/Chip";
import { Input } from "./ui/Input";
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
// reorder) plus a "save the current filters" input; the right column shows the selected view's name
// (renamable), its captured filters as REMOVABLE chips (pruning writes back to the stored view,
// undoable), its default flag, apply, save-current, and delete. Opened from the filter panel's title
// or the saved-views popover; all mutations flow through SavedViewsProvider.
// reorder, or the selected one's order buttons on the right) plus a "save the current filters" input;
// the right column shows the selected view's name (renamable), its captured filters as REMOVABLE
// chips (pruning writes back to the stored view, undoable), its default flag, its place in the order,
// apply, save-current, and delete. Opened from the filter panel's title or the saved-views popover;
// all mutations flow through SavedViewsProvider.
function ViewRow({
view,
selected,
@@ -83,7 +91,12 @@ export default function PersonalViewsModal() {
reorder,
closeModal,
} = useSavedViews();
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
// The grip's keyboard pick-up (space, the arrows, space) is what its screen-reader instructions
// promise; without the sensor they described a gesture the list did not have.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const [selectedId, setSelectedId] = useState<number | null>(activeViewId ?? views[0]?.id ?? null);
const selected = views.find((v) => v.id === selectedId) ?? null;
@@ -154,6 +167,12 @@ export default function PersonalViewsModal() {
const next = arrayMove(ids, ids.indexOf(active.id as number), ids.indexOf(over.id as number));
await reorder(next);
}
// The non-drag way (WCAG 2.5.7): the selected view's Top / Up / Down / Bottom, beside its editor.
const selectedAt = views.findIndex((v) => v.id === selectedId);
function moveSelected(to: number) {
const ids = views.map((v) => v.id);
void reorder(arrayMove(ids, selectedAt, to));
}
async function onCreate() {
const name = createName.trim();
@@ -273,6 +292,9 @@ export default function PersonalViewsModal() {
</div>
)}
</div>
{views.length > 1 && (
<OrderButtons from={selectedAt} count={views.length} onMove={moveSelected} />
)}
<div className="mt-auto flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
<Button
variant="primary"
@@ -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"]);
+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"),
@@ -114,7 +114,7 @@ describe("PlexPlaylistView · a row's menu", () => {
expect(moreOf("The Show")).toBeTruthy();
});
it("lists the title's own actions, then the removal", async () => {
it("lists the title's own actions, its moves, then the removal", async () => {
const info = vi.fn();
mount([movie("1")], info);
await screen.findByText("Movie 1");
@@ -126,6 +126,10 @@ describe("PlexPlaylistView · a row's menu", () => {
"plex.info.title",
"common.copy.title",
"home.pin.add",
"common.order.top",
"common.order.up",
"common.order.down",
"common.order.bottom",
"plex.playlist.removeFromPlaylist",
]);
expect(moreOf("Movie 1").getAttribute("aria-expanded")).toBe("true");
@@ -165,6 +169,10 @@ describe("PlexPlaylistView · a row's menu", () => {
expect(menuRows()).toEqual([
"plex.playlist.playShow",
"common.copy.title",
"common.order.top",
"common.order.up",
"common.order.down",
"common.order.bottom",
"plex.playlist.removeShow",
]);
fireEvent.click(
@@ -198,6 +206,43 @@ describe("PlexPlaylistView · a row's menu", () => {
});
});
const moveRow = (label: string) =>
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!;
describe("PlexPlaylistView · reordering without a drag (WCAG 2.5.7)", () => {
it("moves a title among the blocks from its menu, the moves that go nowhere held off", async () => {
mount([movie("1"), ep("e1", 1, 1), movie("2")]);
await screen.findByText("Movie 1");
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue({} as never);
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
expect((moveRow("common.order.top") as HTMLButtonElement).disabled).toBe(true);
expect((moveRow("common.order.up") as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(moveRow("common.order.bottom"));
// The show moves as one block: Movie 1 lands after Movie 2, the episode stays with its show.
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["e1", "2", "1"]));
});
it("moves a whole show from its group's menu", async () => {
mount([movie("1"), ep("e1", 1, 1), ep("e2", 1, 2)]);
await screen.findByText("The Show");
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue({} as never);
await pick(() => fireEvent.click(moreOf("The Show")), "common.order.up");
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["e1", "e2", "1"]));
});
it("moves an episode only within its own show", async () => {
mount([movie("1"), ep("e1", 1, 1), ep("e2", 1, 2), ep("e3", 1, 3)]);
fireEvent.click(await screen.findByText("The Show")); // open the group
await screen.findByText("Ep e3");
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue({} as never);
await openMenu(() => fireEvent.click(moreOf("Ep e3")));
// Last of three in its show: down is off, though a movie sits above the show in the list.
expect((moveRow("common.order.down") as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(moveRow("common.order.top"));
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["1", "e3", "e1", "e2"]));
});
});
describe("PlexPlaylistView · the keyboard", () => {
it("walks the rows with j and takes out the walked one on Delete", async () => {
mount([movie("1"), movie("2"), movie("3")]);
+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")),
]
: [
@@ -139,4 +139,9 @@ describe("DragHandle", () => {
fireEvent.click(h);
expect(onClick).not.toHaveBeenCalled();
});
it("takes a finger's drag instead of scrolling the page (touch-action: none, WCAG 2.5.7)", () => {
render(<DragHandle label="Move Intro" />);
expect(screen.getByRole("button", { name: "Move Intro" }).className).toContain("touch-none");
});
});
@@ -0,0 +1,74 @@
// @vitest-environment jsdom
//
// The non-drag reorder (WCAG 2.5.7): the four menu rows and the four buttons, one rule — a move that
// would go nowhere is shown off, and the rest report the index the item lands on.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, renderHook, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, o?: { pos?: number; total?: number }) =>
o?.pos !== undefined ? `${key}:${o.pos}/${o.total}` : key,
}),
};
});
import { OrderButtons } from "./OrderButtons";
import { useOrderItems } from "./useOrderItems";
afterEach(cleanup);
describe("useOrderItems", () => {
const rows = (...a: Parameters<ReturnType<typeof useOrderItems>>) =>
renderHook(() => useOrderItems()).result.current(...a);
it("offers top, up, down and bottom, each reporting where the item lands", () => {
const onMove = vi.fn();
const items = rows(2, 5, onMove);
expect(items.map((i) => i.id)).toEqual(["order-top", "order-up", "order-down", "order-bottom"]);
expect(items.map((i) => i.label)).toEqual([
"common.order.top",
"common.order.up",
"common.order.down",
"common.order.bottom",
]);
expect(items[0]!.separatorBefore).toBe(true);
items.forEach((i) => i.onSelect!());
expect(onMove.mock.calls.map((c) => c[0])).toEqual([0, 1, 3, 4]);
});
it("turns off the moves that go nowhere: the first item up, the last down", () => {
expect(rows(0, 3, vi.fn()).map((i) => !!i.disabled)).toEqual([true, true, false, false]);
expect(rows(2, 3, vi.fn()).map((i) => !!i.disabled)).toEqual([false, false, true, true]);
});
it("turns all four off with the reason under the first row only", () => {
const items = rows(1, 3, vi.fn(), { reason: "sorted" });
expect(items.every((i) => i.disabled)).toBe(true);
expect(items.map((i) => ("note" in i ? i.note : undefined))).toEqual([
"sorted",
undefined,
undefined,
undefined,
]);
});
});
describe("OrderButtons", () => {
it("shows the item's place, moves it, and holds the moves that go nowhere off", () => {
const onMove = vi.fn();
render(<OrderButtons from={0} count={3} onMove={onMove} />);
expect(screen.getByRole("group").getAttribute("aria-label")).toBe(
"common.order.label common.order.place:1/3",
);
const btn = (name: string) => screen.getByRole("button", { name }) as HTMLButtonElement;
expect(btn("common.order.top").disabled).toBe(true);
expect(btn("common.order.up").disabled).toBe(true);
fireEvent.click(btn("common.order.down"));
fireEvent.click(btn("common.order.bottom"));
expect(onMove.mock.calls.map((c) => c[0])).toEqual([1, 2]);
});
});
@@ -0,0 +1,59 @@
import { useTranslation } from "react-i18next";
import { MOVES, moveTarget, type MoveTo } from "../../lib/moveInList";
import { IconButton } from "./IconButton";
import { ORDER_ICON } from "./useOrderItems";
// The button form of the non-drag reorder; the rule and the menu form are in `useOrderItems`.
/** The moves as buttons. In full, the four with the item's place ("2 / 5") in front: for a list whose
* selected item is edited beside it (the saved views). `compact`: only Up and Down, ghost, no place —
* for a short list where every item carries its own pair (a side panel's groups in edit mode), named
* by `name` so a screen reader hears WHICH item the pair moves. Focus-safe: the "Up" just pressed on
* the item now first stays focused. */
export function OrderButtons({
from,
count,
onMove,
compact = false,
name,
}: {
from: number;
count: number;
onMove: (to: number) => void;
compact?: boolean;
name?: string;
}) {
const { t } = useTranslation();
const place = t("common.order.place", { pos: from + 1, total: count });
const moves: readonly MoveTo[] = compact ? ["up", "down"] : MOVES;
return (
<div
role="group"
aria-label={[t("common.order.label"), name, place].filter(Boolean).join(" ")}
className={`flex items-center ${compact ? "" : "gap-1"}`}
>
{!compact && (
<span className="mr-1 text-xs text-muted tabular-nums" aria-hidden="true">
{t("common.order.label")} {place}
</span>
)}
{moves.map((to) => {
const Icon = ORDER_ICON[to];
const at = moveTarget(from, count, to);
return (
<IconButton
key={to}
label={t(`common.order.${to}`)}
variant={compact ? "ghost" : "outline"}
disabled={at === null}
onClick={() => {
if (at !== null) onMove(at);
}}
>
<Icon />
</IconButton>
);
})}
</div>
);
}
+10 -3
View File
@@ -5,12 +5,13 @@ import { CSS } from "@dnd-kit/utilities";
import { useSortableItem } from "./sortableA11y";
import { DragHandle } from "./DragHandle";
import { IconButton } from "./IconButton";
import { OrderButtons } from "./OrderButtons";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
// One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag
// handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three
// panels share the same island look and behavior. `collapsible` groups (the default) get a per-group
// handle and an Up / Down pair (reorder) and a hide toggle. Generalized from the feed sidebar's
// WidgetCard so all three panels share the same island look and behavior. `collapsible` groups (the default) get a per-group
// collapse chevron; non-collapsible groups render as a plain always-open label + control row, and
// their title can instead act as a trigger via `onTitleClick` (e.g. feed "Your tags" -> tag manager).
export default function PanelGroup({
@@ -24,6 +25,7 @@ export default function PanelGroup({
onToggleHidden,
onTitleClick,
titleActionLabel,
order,
children,
}: {
id: string;
@@ -36,6 +38,8 @@ export default function PanelGroup({
onToggleHidden: () => void;
onTitleClick?: () => void;
titleActionLabel?: string;
/** Edit mode's non-drag reorder (WCAG 2.5.7): this group's place and how to move it. */
order?: { from: number; count: number; onMove: (to: number) => void };
children: ReactNode;
}) {
const { t } = useTranslation();
@@ -89,9 +93,12 @@ export default function PanelGroup({
</Pressable>
) : (
<span className={`${titleCls} text-muted`}>
<span className="truncate">{title}</span>
{/* In edit mode the name wraps instead of truncating: the grip, the Up / Down pair and
the eye leave a long one ("Feltöltés dátuma") too little room on one line. */}
<span className={editing ? "break-words" : "truncate"}>{title}</span>
</span>
)}
{editing && order && <OrderButtons {...order} compact name={title} />}
{editing ? (
<IconButton
onClick={onToggleHidden}
@@ -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();
});
});
+19 -6
View File
@@ -76,13 +76,21 @@ export default function PanelGroups({
pos: renderedIds.indexOf(String(id)) + 1,
total: renderedIds.length,
}));
// The stored order, plus any available group it does not hold yet (see above): a move — by drag
// or by a group's Up / Down — lands in it, so a group appended after the layout was saved moves too.
const fullOrder = [
...layout.order,
...orderedAvailable.filter((id) => !layout.order.includes(id)),
];
function move(id: string, overId: string) {
const from = fullOrder.indexOf(id);
const to = fullOrder.indexOf(overId);
if (from < 0 || to < 0 || from === to) return;
setLayout({ ...layout, order: arrayMove(fullOrder, from, to) });
}
function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
const from = layout.order.indexOf(active.id as string);
const to = layout.order.indexOf(over.id as string);
if (from < 0 || to < 0) return;
setLayout({ ...layout, order: arrayMove(layout.order, from, to) });
if (over) move(String(active.id), String(over.id));
}
return (
@@ -94,7 +102,7 @@ export default function PanelGroups({
>
<SortableContext items={renderedIds} strategy={verticalListSortingStrategy}>
<div className="space-y-3">
{renderedIds.map((id) => (
{renderedIds.map((id, i) => (
<PanelGroup
key={id}
id={id}
@@ -107,6 +115,11 @@ export default function PanelGroups({
onToggleHidden={() => toggleHidden(id)}
onTitleClick={byId.get(id)!.onTitleClick}
titleActionLabel={byId.get(id)!.titleActionLabel}
order={{
from: i,
count: renderedIds.length,
onMove: (to) => move(id, renderedIds[to]!),
}}
>
{byId.get(id)!.body}
</PanelGroup>
+1 -1
View File
@@ -39,7 +39,7 @@ domain-free.
| area | components |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a
link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
@@ -0,0 +1,46 @@
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowDownToLine, ArrowUp, ArrowUpToLine } from "lucide-react";
import { MOVES, moveTarget, type MoveTo } from "../../lib/moveInList";
import type { ActionMenuItem } from "./ActionMenu";
// The non-drag reorder (WCAG 2.5.7, docs/DESIGN.md "Reordering"), in its two forms: a row menu's four
// rows (here: a playlist item, a Plex playlist row) and buttons (`OrderButtons`: four beside the
// selected item of the saved views' editor, or an Up / Down pair on every group of a side panel in
// edit mode). Same labels, same glyphs, same rule: a move that would go nowhere — the first item up,
// the last down — is shown off, not hidden, so the moves always sit in the same place.
export const ORDER_ICON: Record<MoveTo, typeof ArrowUp> = {
top: ArrowUpToLine,
up: ArrowUp,
down: ArrowDown,
bottom: ArrowDownToLine,
};
/** The four move rows of a menu, for the item at `from` in a list of `count`; `onMove` gets the index
* it lands on. `off` turns all four off (the list is not in its stored order; a write in flight) and
* its reason, if any, goes under the FIRST row only — one explanation for the group, not the same
* line four times. */
export function useOrderItems() {
const { t } = useTranslation();
return (
from: number,
count: number,
onMove: (to: number) => void,
off?: { reason?: string },
): ActionMenuItem[] =>
MOVES.map((to, i) => {
const Icon = ORDER_ICON[to];
const at = moveTarget(from, count, to);
return {
id: `order-${to}`,
label: t(`common.order.${to}`),
icon: <Icon className="w-4 h-4" />,
separatorBefore: i === 0,
disabled: !!off || at === null,
note: i === 0 ? off?.reason : undefined,
onSelect: () => {
if (at !== null) onMove(at);
},
};
});
}
+8
View File
@@ -43,6 +43,14 @@
"dropped": "“{{title}}” dropped at position {{pos}} of {{total}}.",
"cancelled": "Move cancelled; “{{title}}” is back at position {{pos}}."
},
"order": {
"label": "Order",
"place": "{{pos}} / {{total}}",
"top": "Move to top",
"up": "Move up",
"down": "Move down",
"bottom": "Move to bottom"
},
"copy": {
"title": "Copy title",
"name": "Copy name",
@@ -91,10 +91,6 @@
"not_in_playlist": "That video isn't in this playlist any more — it changed somewhere else.",
"same_playlist": "That video is already in this playlist."
},
"orderTop": "Move to top",
"orderUp": "Move up",
"orderDown": "Move down",
"orderBottom": "Move to bottom",
"railMenu": "Playlist actions",
"builtinLocked": "The built-in {{name}} list can’t be renamed or deleted.",
"removeItem": "Remove",
+1 -1
View File
@@ -5,7 +5,7 @@
"resetDefaults": "Reset to defaults",
"customize": "Customize sidebar",
"done": "Done",
"editHint": "Drag to reorder · eye to show/hide",
"editHint": "Drag or use the arrows to reorder · eye to show/hide",
"dragToReorder": "Drag to reorder",
"showWidget": "Show widget",
"hideWidget": "Hide widget",
+8
View File
@@ -43,6 +43,14 @@
"dropped": "„{{title}}” letéve a(z) {{pos}}. helyre a(z) {{total}} közül.",
"cancelled": "Mozgatás megszakítva; „{{title}}” visszakerült a(z) {{pos}}. helyre."
},
"order": {
"label": "Sorrend",
"place": "{{pos}} / {{total}}",
"top": "Legfelülre",
"up": "Eggyel feljebb",
"down": "Eggyel lejjebb",
"bottom": "Legalulra"
},
"copy": {
"title": "Cím másolása",
"name": "Név másolása",
@@ -91,10 +91,6 @@
"not_in_playlist": "Ez a videó már nincs ebben a listában — időközben máshol megváltozott.",
"same_playlist": "Ez a videó már ebben a listában van."
},
"orderTop": "Legfelülre",
"orderUp": "Eggyel feljebb",
"orderDown": "Eggyel lejjebb",
"orderBottom": "Legalulra",
"railMenu": "Lejátszási lista műveletei",
"builtinLocked": "A beépített „{{name}}” lista nem nevezhető át és nem törölhető.",
"removeItem": "Eltávolítás",
+1 -1
View File
@@ -5,7 +5,7 @@
"resetDefaults": "Visszaállítás alapértelmezettre",
"customize": "Oldalsáv testreszabása",
"done": "Kész",
"editHint": "Húzd az átrendezéshez · szem a megjelenítéshez/elrejtéshez",
"editHint": "Húzd vagy a nyilakkal rendezd át · szem a megjelenítéshez/elrejtéshez",
"dragToReorder": "Húzd az átrendezéshez",
"showWidget": "Modul megjelenítése",
"hideWidget": "Modul elrejtése",
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { moveTarget } from "./moveInList";
describe("moveTarget", () => {
it("sends an item to the top, one up, one down, or to the bottom", () => {
expect(moveTarget(2, 5, "top")).toBe(0);
expect(moveTarget(2, 5, "up")).toBe(1);
expect(moveTarget(2, 5, "down")).toBe(3);
expect(moveTarget(2, 5, "bottom")).toBe(4);
});
it("is null for a move that goes nowhere: the first up, the last down", () => {
expect(moveTarget(0, 5, "top")).toBeNull();
expect(moveTarget(0, 5, "up")).toBeNull();
expect(moveTarget(4, 5, "down")).toBeNull();
expect(moveTarget(4, 5, "bottom")).toBeNull();
expect(moveTarget(0, 1, "down")).toBeNull();
});
it("is null for an index outside the list", () => {
expect(moveTarget(-1, 5, "top")).toBeNull();
expect(moveTarget(5, 5, "up")).toBeNull();
});
});
+14
View File
@@ -0,0 +1,14 @@
// The non-drag way to reorder a list (WCAG 2.5.7, docs/DESIGN.md "Reordering"): anything a grip can
// drag also moves by Top / Up / Down / Bottom — a menu's rows or a row of buttons, one rule for both.
export type MoveTo = "top" | "up" | "down" | "bottom";
export const MOVES: readonly MoveTo[] = ["top", "up", "down", "bottom"];
/** Where `to` sends the item at `from` in a list of `count`, or null when it would not move (the
* first item going up, the last going down, a stale index) — the caller shows that move as off. */
export function moveTarget(from: number, count: number, to: MoveTo): number | null {
if (from < 0 || from >= count) return null;
const at = to === "top" ? 0 : to === "bottom" ? count - 1 : to === "up" ? from - 1 : from + 1;
return at === from || at < 0 || at >= count ? null : at;
}