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
@@ -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);
},
};
});
}