feat(playlists): select rows with a box, Shift for a range, x and Esc
A box first in every row and one over the column for the rows shown, the selected row tinted. A filter keeps what it hides selected; another list starts over; a row that left the list drops out. Watch later has none. A box no longer counts as a typing target: a click on one left the focus there and the page's keys went dead.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
// @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 { navItemAttrs, useListNav, type NavSelection } from "./listNav";
|
||||
import { useActiveScopes } from "./keymap";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function Rows({ selection }: { selection?: NavSelection }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: true, selection });
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{[0, 1, 2].map((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),
|
||||
});
|
||||
|
||||
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("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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user