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