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 && ( + + )}