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:
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user