// @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(null); useListNav(ref, { enabled: true, selection, onDelete }); return (
{Array.from({ length: rows }, (_, i) => (
))}
); } function Scopes() { return {useActiveScopes().join(" ")}; } 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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(press("Escape").defaultPrevented).toBe(true); expect(s.clear).toHaveBeenCalledTimes(1); cleanup(); const idle = selection(false); render(); 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 ", async () => { render(); const leaving = screen.getByTestId("r1"); followRemoval(screen.getByTestId("r1-action")); const list = leaving.parentElement!; list.removeChild(leaving); list.querySelector("[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(); 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(); 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(); 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(); press("j"); press("Delete"); expect(onDelete).toHaveBeenCalledExactlyOnceWith(0); }); it("leaves both keys alone in a list that takes no selection", () => { render(); 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( <> , ); await waitFor(() => expect(screen.getByTestId("scopes").textContent).toContain("listSelect")); cleanup(); render( <> , ); await waitFor(() => expect(screen.getByTestId("scopes").textContent).toContain("list")); expect(screen.getByTestId("scopes").textContent).not.toContain("listSelect"); }); });