An Esc during a pointer drag is spent on the drag and leaves the selection. A wheel over the rail re-reads the target each frame, so a drop lands on the row under the pointer. After a bulk removal the focus goes to the next row left, and to the list's menu when the list is emptied. remove-bulk names the videos that went, and its Undo brings back only those. An Undo of a move keeps each row (and when it was saved), leaves out a video that moved on from the target, closes up its place, and says how many came back. Delete with every tick hidden removes nothing and says why. A list gone before the lock answers 404, and a body without its videos 422. One removal path now: the single row goes through the bulk one, the same toast and undo, which also retires the add-at-position route. railDrop reuses the store and scrollParent, the drag image the overlay and the z-index token, and the sensors keep their number. The count is read out from a region that outlives the band. Tests fill the gaps the review named, each seen failing.
206 lines
6.8 KiB
TypeScript
206 lines
6.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// `x` selects the walked item and Esc clears, in a list that takes a selection (a playlist's rows).
|
|
// Its own file: listNav keeps module state.
|
|
import { useRef } from "react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
import { followRemoval, navItemAttrs, useListNav, type NavSelection } from "./listNav";
|
|
import { useActiveScopes } from "./keymap";
|
|
|
|
afterEach(cleanup);
|
|
|
|
function Rows({
|
|
selection,
|
|
onDelete,
|
|
rows = 3,
|
|
}: {
|
|
selection?: NavSelection;
|
|
onDelete?: (index: number) => void;
|
|
rows?: number;
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useListNav(ref, { enabled: true, selection, onDelete });
|
|
return (
|
|
<div ref={ref}>
|
|
{Array.from({ length: rows }, (_, i) => (
|
|
<div key={i} {...navItemAttrs(i)} data-testid={`r${i}`}>
|
|
<button data-testid={`r${i}-action`}>action</button>
|
|
<input data-testid={`r${i}-input`} />
|
|
<input type="checkbox" data-testid={`r${i}-box`} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Scopes() {
|
|
return <output data-testid="scopes">{useActiveScopes().join(" ")}</output>;
|
|
}
|
|
|
|
function press(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
|
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
|
|
(document.activeElement ?? document.body).dispatchEvent(e);
|
|
return e;
|
|
}
|
|
|
|
const selection = (cleared = true) => ({
|
|
toggle: vi.fn<(index: number) => void>(),
|
|
clear: vi.fn(() => cleared),
|
|
removeSelected: vi.fn(() => false),
|
|
});
|
|
|
|
describe("useListNav · selecting", () => {
|
|
it("toggles the walked item on x", () => {
|
|
const s = selection();
|
|
render(<Rows selection={s} />);
|
|
press("j");
|
|
press("j");
|
|
const e = press("x");
|
|
expect(s.toggle).toHaveBeenCalledExactlyOnceWith(1);
|
|
expect(e.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it("toggles the item a control inside it holds the focus for", () => {
|
|
const s = selection();
|
|
render(<Rows selection={s} />);
|
|
screen.getByTestId("r2-action").focus();
|
|
press("x");
|
|
expect(s.toggle).toHaveBeenCalledExactlyOnceWith(2);
|
|
});
|
|
|
|
it("goes on answering with the focus on the row's box, where a click leaves it", () => {
|
|
const s = selection();
|
|
render(<Rows selection={s} />);
|
|
screen.getByTestId("r1-box").focus();
|
|
press("x");
|
|
expect(s.toggle).toHaveBeenCalledExactlyOnceWith(1);
|
|
press("j");
|
|
expect(document.activeElement).toBe(screen.getByTestId("r2"));
|
|
});
|
|
|
|
it("does nothing on x with no item under the keyboard", () => {
|
|
const s = selection();
|
|
render(<Rows selection={s} />);
|
|
const e = press("x");
|
|
expect(s.toggle).not.toHaveBeenCalled();
|
|
expect(e.defaultPrevented).toBe(false);
|
|
});
|
|
|
|
it("types an x in a field instead of selecting", () => {
|
|
const s = selection();
|
|
render(<Rows selection={s} />);
|
|
screen.getByTestId("r0-input").focus();
|
|
press("x");
|
|
expect(s.toggle).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not flip back and forth while x is held down", () => {
|
|
const s = selection();
|
|
render(<Rows selection={s} />);
|
|
press("j");
|
|
press("x");
|
|
press("x", { repeat: true });
|
|
expect(s.toggle).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("clears on Esc wherever the focus is, and spends the key only when it cleared", () => {
|
|
const s = selection();
|
|
render(<Rows selection={s} />);
|
|
expect(press("Escape").defaultPrevented).toBe(true);
|
|
expect(s.clear).toHaveBeenCalledTimes(1);
|
|
cleanup();
|
|
|
|
const idle = selection(false);
|
|
render(<Rows selection={idle} />);
|
|
expect(press("Escape").defaultPrevented).toBe(false);
|
|
expect(idle.clear).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("takes the slot of an item that left by other hands, the focus on <body>", async () => {
|
|
render(<Rows />);
|
|
const leaving = screen.getByTestId("r1");
|
|
followRemoval(screen.getByTestId("r1-action"));
|
|
const list = leaving.parentElement!;
|
|
list.removeChild(leaving);
|
|
list.querySelector<HTMLElement>("[data-testid=r2]")!.setAttribute("data-nav-index", "1");
|
|
await waitFor(() => expect(document.activeElement).toBe(screen.getByTestId("r2")));
|
|
});
|
|
|
|
it("goes on to the next row left, past what a selection took from above it", async () => {
|
|
const s = selection();
|
|
s.removeSelected.mockImplementation(() => {
|
|
// As a commit does it, after the key's handler: the two rows go, and the ones left are numbered
|
|
// from the top.
|
|
queueMicrotask(() => {
|
|
screen.getByTestId("r0").remove();
|
|
screen.getByTestId("r1").remove();
|
|
screen.getByTestId("r2").setAttribute("data-nav-index", "0");
|
|
screen.getByTestId("r3").setAttribute("data-nav-index", "1");
|
|
});
|
|
return true;
|
|
});
|
|
render(<Rows selection={s} rows={4} />);
|
|
const r2 = screen.getByTestId("r2");
|
|
screen.getByTestId("r1-box").focus();
|
|
press("Delete");
|
|
await waitFor(() => expect(document.activeElement).toBe(r2));
|
|
});
|
|
|
|
it("leaves the focus where it is when something else took it meanwhile", async () => {
|
|
render(<Rows />);
|
|
const leaving = screen.getByTestId("r1");
|
|
const other = screen.getByTestId("r0-input");
|
|
other.focus();
|
|
followRemoval(screen.getByTestId("r1-action"));
|
|
leaving.remove();
|
|
await new Promise((r) => setTimeout(r, 60));
|
|
expect(document.activeElement).toBe(other);
|
|
});
|
|
|
|
it("has Delete go to the selection first, wherever the focus is", () => {
|
|
const s = selection();
|
|
s.removeSelected.mockReturnValue(true);
|
|
const onDelete = vi.fn();
|
|
render(<Rows selection={s} onDelete={onDelete} />);
|
|
expect(press("Delete").defaultPrevented).toBe(true);
|
|
expect(s.removeSelected).toHaveBeenCalledTimes(1);
|
|
expect(onDelete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("has Delete go on to the walked row when there is no selection", () => {
|
|
const s = selection();
|
|
const onDelete = vi.fn();
|
|
render(<Rows selection={s} onDelete={onDelete} />);
|
|
press("j");
|
|
press("Delete");
|
|
expect(onDelete).toHaveBeenCalledExactlyOnceWith(0);
|
|
});
|
|
|
|
it("leaves both keys alone in a list that takes no selection", () => {
|
|
render(<Rows />);
|
|
press("j");
|
|
expect(press("x").defaultPrevented).toBe(false);
|
|
expect(press("Escape").defaultPrevented).toBe(false);
|
|
});
|
|
|
|
it("lists the keys only over a list that takes a selection and has items", async () => {
|
|
render(
|
|
<>
|
|
<Scopes />
|
|
<Rows selection={selection()} />
|
|
</>,
|
|
);
|
|
await waitFor(() => expect(screen.getByTestId("scopes").textContent).toContain("listSelect"));
|
|
cleanup();
|
|
render(
|
|
<>
|
|
<Scopes />
|
|
<Rows />
|
|
</>,
|
|
);
|
|
await waitFor(() => expect(screen.getByTestId("scopes").textContent).toContain("list"));
|
|
expect(screen.getByTestId("scopes").textContent).not.toContain("listSelect");
|
|
});
|
|
});
|