From 4d0abc61d59621ea82bb1fb4c189a18ad7261475 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 26 Sep 2026 18:14:28 +0200 Subject: [PATCH] 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. --- .../PersonalViewsModal.dom.test.tsx | 47 ++++++++++++ .../src/components/PersonalViewsModal.tsx | 34 +++++++-- .../src/components/Playlists.dom.test.tsx | 6 +- frontend/src/components/Playlists.tsx | 50 ++++--------- .../PlexPlaylistView.menu.dom.test.tsx | 47 +++++++++++- frontend/src/components/PlexPlaylistView.tsx | 71 ++++++++++++------ .../src/components/ui/IconButton.dom.test.tsx | 5 ++ .../components/ui/OrderButtons.dom.test.tsx | 74 +++++++++++++++++++ frontend/src/components/ui/OrderButtons.tsx | 59 +++++++++++++++ frontend/src/components/ui/PanelGroup.tsx | 13 +++- .../components/ui/PanelGroups.dom.test.tsx | 58 +++++++++++++++ frontend/src/components/ui/PanelGroups.tsx | 25 +++++-- frontend/src/components/ui/README.md | 2 +- frontend/src/components/ui/useOrderItems.tsx | 46 ++++++++++++ frontend/src/i18n/locales/en/common.json | 8 ++ frontend/src/i18n/locales/en/playlists.json | 4 - frontend/src/i18n/locales/en/sidebar.json | 2 +- frontend/src/i18n/locales/hu/common.json | 8 ++ frontend/src/i18n/locales/hu/playlists.json | 4 - frontend/src/i18n/locales/hu/sidebar.json | 2 +- frontend/src/lib/moveInList.test.ts | 24 ++++++ frontend/src/lib/moveInList.ts | 14 ++++ 22 files changed, 512 insertions(+), 91 deletions(-) create mode 100644 frontend/src/components/ui/OrderButtons.dom.test.tsx create mode 100644 frontend/src/components/ui/OrderButtons.tsx create mode 100644 frontend/src/components/ui/PanelGroups.dom.test.tsx create mode 100644 frontend/src/components/ui/useOrderItems.tsx create mode 100644 frontend/src/lib/moveInList.test.ts create mode 100644 frontend/src/lib/moveInList.ts diff --git a/frontend/src/components/PersonalViewsModal.dom.test.tsx b/frontend/src/components/PersonalViewsModal.dom.test.tsx index 85001847..dc3ea327 100644 --- a/frontend/src/components/PersonalViewsModal.dom.test.tsx +++ b/frontend/src/components/PersonalViewsModal.dom.test.tsx @@ -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")); + }); +}); diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx index 605df2e8..b7a2eeff 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/components/PersonalViewsModal.tsx @@ -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(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() { )} + {views.length > 1 && ( + + )}