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:
2026-11-01 20:49:59 +01:00
parent cd773e8f33
commit 8cf8c4139c
20 changed files with 710 additions and 29 deletions
+23
View File
@@ -93,6 +93,24 @@ describe("isTypingTarget", () => {
it("is false on ordinary content", () => {
expect(isTypingTarget(press({ key: "a" }))).toBe(false);
});
it("is false on a box, where no letter types: the page's keys go on working after a tick", () => {
for (const type of ["checkbox", "radio"]) {
const el = document.createElement("input");
el.type = type;
document.body.append(el);
expect(isTypingTarget(press({ key: "a" }, el))).toBe(false);
el.remove();
}
});
it("stays true on a slider, whose arrows are its own", () => {
const el = document.createElement("input");
el.type = "range";
document.body.append(el);
expect(isTypingTarget(press({ key: "ArrowLeft" }, el))).toBe(true);
el.remove();
});
});
describe("isActivationTarget", () => {
@@ -101,6 +119,11 @@ describe("isActivationTarget", () => {
document.body.append(btn);
expect(isActivationTarget(press({ key: " " }, btn))).toBe(true);
btn.remove();
const box = document.createElement("input");
box.type = "checkbox";
document.body.append(box);
expect(isActivationTarget(press({ key: " " }, box))).toBe(true);
box.remove();
expect(isActivationTarget(press({ key: " " }))).toBe(false);
});
});
+20 -2
View File
@@ -33,6 +33,7 @@ export type KeyScope =
| "search"
| "list"
| "listDelete"
| "listSelect"
| "undo"
| "contextMenu"
| "ytPlayer"
@@ -46,6 +47,7 @@ const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
"search",
"list",
"listDelete",
"listSelect",
]);
/** True for a scope that acts on the page beneath the overlays (see `KeyScope`). */
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
@@ -138,6 +140,15 @@ export const SHORTCUTS = [
// Its own scope beside `list`: only a list that can take an item out (a playlist's rows) registers
// it, so the sheet does not promise Delete on the feed or a Plex grid, where nothing answers it.
{ id: "remove", scope: "listDelete", keys: ["Delete"], labelKey: "keys.listRemove" },
// …and only a list whose rows can be selected registers these. Escape stays quiet in a field: the
// field's own Escape comes first, and a selection is not what the reader is in while typing.
{ id: "select", scope: "listSelect", keys: ["x"], labelKey: "keys.listSelect" },
{
id: "clearSelection",
scope: "listSelect",
keys: ["Escape"],
labelKey: "keys.listClearSelection",
},
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
@@ -264,15 +275,22 @@ export function isTypingTarget(e: KeyboardEvent): boolean {
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
if (!el) return false;
const tag = (el.tagName || "").toLowerCase();
// A box takes no letters, and a click on a table row's box leaves the focus on it: `j` went dead
// there. A slider stays a field, as its arrows are its own.
if (isBox(el)) return false;
return tag === "input" || tag === "textarea" || tag === "select" || !!el.isContentEditable;
}
function isBox(el: HTMLElement | null): boolean {
return el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio");
}
/** True while focus is on something Space/Enter activates natively. Both players defer Space to a
* focused button ("Skip intro") rather than toggling playback; this is that shared rule. */
export function isActivationTarget(e: KeyboardEvent): boolean {
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
const tag = (el?.tagName || "").toLowerCase();
return tag === "button" || tag === "a";
return tag === "button" || tag === "a" || isBox(el);
}
/** One printable character that is neither a letter nor a digit (`[`, `]`, `,`). */
@@ -462,7 +480,7 @@ export function useActiveScopes(): KeyScope[] {
// registers `page` in its own effect, which React runs after every child's — so by mount order
// the module jumps would head the sheet, above the list keys of the page you are looking at.
const surfaces = live.filter((s) => !isPageLevelScope(s));
const pageLevel = (["list", "listDelete", "search", "page"] as const).filter((s) =>
const pageLevel = (["list", "listSelect", "listDelete", "search", "page"] as const).filter((s) =>
live.includes(s),
);
return [...surfaces, ...pageLevel, "global"];
@@ -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");
});
});
+34
View File
@@ -36,6 +36,15 @@ interface NavList {
readonly reveal: (index: number) => void;
/** Takes the item at `index` out of the list (Delete), or null for a list that cannot. */
readonly remove: () => ((index: number) => void) | null;
/** The list's selection (`x`, Esc), or null for a list whose items cannot be selected. */
readonly selection: () => NavSelection | null;
}
/** What `x` and Esc do in a list whose items can be selected. `clear` says whether it cleared
* anything: an Esc that did not is left for whoever else answers it. */
export interface NavSelection {
readonly toggle: (index: number) => void;
readonly clear: () => boolean;
}
const lists = new Map<HTMLElement, NavList>();
@@ -432,6 +441,27 @@ function onKey(e: KeyboardEvent): void {
},
{ inTopLayer },
);
dispatch(
e,
"listSelect",
{
// Anywhere in the item, unlike Delete: a tick is undone by the same key, and after a click on
// the box the focus sits inside the row. Held down, it must not flip back and forth.
select: (ev) => {
const cur = current();
const selection = cur?.list.selection();
if (!cur || !selection || ev.repeat) return;
ev.preventDefault();
selection.toggle(cur.index);
},
clearSelection: (ev) => {
let cleared = false;
for (const list of ordered()) if (list.selection()?.clear()) cleared = true;
if (cleared) ev.preventDefault();
},
},
{ inTopLayer },
);
}
// How long a removal gets to take its row out of the DOM before the focus gives up following it.
@@ -599,6 +629,8 @@ export function useListNav(
reveal?: (index: number) => void;
/** A list whose items the Delete key takes out (a playlist's rows): the item's index. */
onDelete?: (index: number) => void;
/** A list whose items can be selected, by `x` and Esc. */
selection?: NavSelection;
},
): void {
// Read when a key is pressed, so the registration below never re-runs for a new `opts` object.
@@ -609,6 +641,7 @@ export function useListNav(
const [hasItems, setHasItems] = useState(false);
useKeyScope("list", opts.enabled && hasItems);
useKeyScope("listDelete", opts.enabled && hasItems && !!opts.onDelete);
useKeyScope("listSelect", opts.enabled && hasItems && !!opts.selection);
useEffect(() => {
const el = ref.current;
if (!opts.enabled || !el) return;
@@ -618,6 +651,7 @@ export function useListNav(
cols: () => latestOpts().cols ?? measuredCols(el),
reveal: (i) => latestOpts().reveal?.(i),
remove: () => latestOpts().onDelete ?? null,
selection: () => latestOpts().selection ?? null,
});
el.setAttribute(LIST, "");
const check = () => setHasItems(el.querySelector(`[${INDEX}]`) !== null);
+2
View File
@@ -24,6 +24,8 @@ type ColumnFilter<T> =
export interface Column<T> {
key: string;
header: string;
// A header that is a control of its own (the box that selects every row), in place of `header`.
headerCell?: ReactNode;
render: (row: T) => ReactNode;
align?: "left" | "right" | "center";
width?: string;
@@ -0,0 +1,159 @@
// @vitest-environment jsdom
import { describe, it, expect } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useRowSelection } from "./useRowSelection";
const ABCDE = ["a", "b", "c", "d", "e"];
function setup(shown = ABCDE, all = ABCDE, scope: unknown = 1) {
return renderHook(
(p: { all: string[]; shown: string[]; scope: unknown }) =>
useRowSelection(p.all, p.shown, p.scope),
{ initialProps: { all, shown, scope } },
);
}
describe("useRowSelection", () => {
it("toggles one row at a time, and says the selected rows in the order shown", () => {
const { result } = setup();
act(() => result.current.toggle("d"));
act(() => result.current.toggle("b"));
expect(result.current.shownSelected).toEqual(["b", "d"]);
act(() => result.current.toggle("d"));
expect(result.current.shownSelected).toEqual(["b"]);
expect(result.current.isSelected("b")).toBe(true);
expect(result.current.isSelected("d")).toBe(false);
});
it("selects the range from the last row toggled, in either direction", () => {
const { result } = setup();
act(() => result.current.toggle("b"));
act(() => result.current.toggle("d", true));
expect(result.current.shownSelected).toEqual(["b", "c", "d"]);
act(() => {
result.current.clear();
});
act(() => result.current.toggle("d"));
act(() => result.current.toggle("b", true));
expect(result.current.shownSelected).toEqual(["b", "c", "d"]);
});
it("takes a range's state from the row the range ends on: it can clear rows too", () => {
const { result } = setup();
act(() => result.current.toggle("a"));
act(() => result.current.toggle("e", true));
expect(result.current.shownSelected).toEqual(ABCDE);
// `e` is selected, so a range ending on it clears; the anchor moved to `e` with the last click.
act(() => result.current.toggle("b", true));
expect(result.current.shownSelected).toEqual(["a"]);
});
it("leaves rows outside the range alone", () => {
const { result } = setup();
act(() => result.current.toggle("a"));
act(() => result.current.toggle("c"));
act(() => result.current.toggle("e", true));
expect(result.current.shownSelected).toEqual(["a", "c", "d", "e"]);
});
it("follows the order on screen, not the stored one", () => {
const { result } = setup(["e", "c", "a", "b", "d"]);
act(() => result.current.toggle("c"));
act(() => result.current.toggle("b", true));
expect(result.current.shownSelected).toEqual(["c", "a", "b"]);
});
it("makes a range with no row to start from a plain toggle", () => {
const { result } = setup();
act(() => result.current.toggle("c", true));
expect(result.current.shownSelected).toEqual(["c"]);
});
it("makes a range whose first row a filter hid a plain toggle", () => {
const { result, rerender } = setup();
act(() => result.current.toggle("a"));
rerender({ all: ABCDE, shown: ["c", "d", "e"], scope: 1 });
act(() => result.current.toggle("e", true));
expect(result.current.shownSelected).toEqual(["e"]);
});
it("has the header box cover the rows shown, and keep what a filter hides", () => {
const { result, rerender } = setup();
expect(result.current.headerState).toBe("none");
act(() => result.current.toggle("a"));
expect(result.current.headerState).toBe("some");
rerender({ all: ABCDE, shown: ["c", "d"], scope: 1 });
// The one selected row is hidden: nothing SHOWN is selected, and acting counts what is shown.
expect(result.current.headerState).toBe("none");
expect(result.current.shownSelected).toEqual([]);
act(() => result.current.toggleAllShown());
expect(result.current.headerState).toBe("all");
expect(result.current.shownSelected).toEqual(["c", "d"]);
act(() => result.current.toggleAllShown());
expect(result.current.headerState).toBe("none");
rerender({ all: ABCDE, shown: ABCDE, scope: 1 });
expect(result.current.shownSelected).toEqual(["a"]);
});
it("selects every row shown from a partial selection", () => {
const { result } = setup();
act(() => result.current.toggle("b"));
act(() => result.current.toggleAllShown());
expect(result.current.shownSelected).toEqual(ABCDE);
});
it("has no header state to show over no rows", () => {
const { result } = setup([], []);
expect(result.current.headerState).toBe("none");
act(() => result.current.toggleAllShown());
expect(result.current.headerState).toBe("none");
});
it("drops a row that left the list, so it does not come back selected", () => {
const { result, rerender } = setup();
act(() => result.current.toggle("b"));
act(() => result.current.toggle("c"));
rerender({ all: ["a", "c", "d", "e"], shown: ["a", "c", "d", "e"], scope: 1 });
expect(result.current.shownSelected).toEqual(["c"]);
rerender({ all: ABCDE, shown: ABCDE, scope: 1 });
expect(result.current.shownSelected).toEqual(["c"]);
});
it("clears on a change of scope, in the render that changes it", () => {
const { result, rerender } = setup();
act(() => result.current.toggle("a"));
// The same videos in another list are not selected there.
rerender({ all: ABCDE, shown: ABCDE, scope: 2 });
expect(result.current.shownSelected).toEqual([]);
act(() => result.current.toggle("c", true));
expect(result.current.shownSelected).toEqual(["c"]);
});
it("says whether clearing did anything", () => {
const { result } = setup();
let cleared = true;
act(() => {
cleared = result.current.clear();
});
expect(cleared).toBe(false);
act(() => result.current.toggle("a"));
act(() => {
cleared = result.current.clear();
});
expect(cleared).toBe(true);
expect(result.current.shownSelected).toEqual([]);
});
it("counts a selected row a filter hides as something to clear", () => {
const { result, rerender } = setup();
act(() => result.current.toggle("a"));
rerender({ all: ABCDE, shown: ["c"], scope: 1 });
let cleared = false;
act(() => {
cleared = result.current.clear();
});
expect(cleared).toBe(true);
rerender({ all: ABCDE, shown: ABCDE, scope: 1 });
expect(result.current.shownSelected).toEqual([]);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { useState } from "react";
const NONE: ReadonlySet<string> = new Set();
/** Which rows of a table are ticked, for the bulk actions over them. `all` is every row the list
* holds and `shown` the ones on screen, in screen order. A row a filter hides stays selected and
* comes back ticked, but `shownSelected`, what an action covers, leaves it out: nothing happens to a
* row the reader cannot see. A row that left the list drops out for good, or an Undo would bring it
* back ticked. A change of `scope` starts over: the same video in another list is another row. */
export function useRowSelection(all: readonly string[], shown: readonly string[], scope: unknown) {
const [selected, setSelected] = useState(NONE);
// The last row toggled: where a Shift range starts.
const [anchor, setAnchor] = useState<string | null>(null);
const [selectedFor, setSelectedFor] = useState(scope);
if (selectedFor !== scope) {
setSelectedFor(scope);
setSelected(NONE);
setAnchor(null);
}
const held = new Set(all);
const kept = [...selected].filter((id) => held.has(id));
if (kept.length !== selected.size) setSelected(new Set(kept));
const shownSelected = shown.filter((id) => selected.has(id));
function toggle(id: string, range = false) {
const on = !selected.has(id);
const from = range && anchor != null ? shown.indexOf(anchor) : -1;
const to = shown.indexOf(id);
// Taken from where the range ends, the way a mail list does: it can clear a run of rows too.
const rows =
from >= 0 && to >= 0 ? shown.slice(Math.min(from, to), Math.max(from, to) + 1) : [id];
const next = new Set(selected);
for (const row of rows) {
if (on) next.add(row);
else next.delete(row);
}
setSelected(next);
setAnchor(id);
}
function toggleAllShown() {
const next = new Set(selected);
const on = shownSelected.length < shown.length;
for (const row of shown) {
if (on) next.add(row);
else next.delete(row);
}
setSelected(next);
setAnchor(null);
}
/** Says whether there was anything to clear: an Esc that cleared nothing is not spent. */
function clear(): boolean {
const had = selected.size > 0;
if (had) setSelected(NONE);
setAnchor(null);
return had;
}
return {
shownSelected,
isSelected: (id: string) => selected.has(id),
headerState:
shownSelected.length === 0
? ("none" as const)
: shownSelected.length === shown.length
? ("all" as const)
: ("some" as const),
toggle,
toggleAllShown,
clear,
};
}