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
@@ -863,9 +863,9 @@ describe("Playlists · a narrow table", () => {
el.classList.contains("hidden") && el.classList.contains("@2xl:table-cell");
const heads = [...document.querySelectorAll("thead th")];
const cells = [...document.querySelectorAll("tbody tr:first-child td")];
// Columns: grip, #, title, channel, length, "…". Length went too (the owner's pick).
expect(heads.map(narrowHidden)).toEqual([false, true, false, true, true, false]);
expect(cells.map(narrowHidden)).toEqual([false, true, false, true, true, false]);
// Columns: box, grip, #, title, channel, length, "…". Length went too (the owner's pick).
expect(heads.map(narrowHidden)).toEqual([false, false, true, false, true, true, false]);
expect(cells.map(narrowHidden)).toEqual([false, false, true, false, true, true, false]);
});
it("carries the hidden length on the picture and the channel under a two-line title", async () => {
@@ -1036,3 +1036,94 @@ describe("Playlists · the view in the address", () => {
expect(param("plgroup")).toBeNull();
});
});
describe("Playlists — selecting rows", () => {
const box = (title: string) =>
screen.findByRole("checkbox", { name: `playlists.selectRow:${title}` });
const rowOf = (el: HTMLElement) => el.closest("tr")!;
const header = () => screen.findByRole("checkbox", { name: "playlists.selectAllShown" });
const three = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
const press = (key: string) =>
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
it("gives every row a box, first in the row, and marks a selected row", async () => {
mount(three);
const a = (await box("Video A")) as HTMLInputElement;
expect(rowOf(a).cells[0]!.contains(a)).toBe(true);
expect(a.checked).toBe(false);
expect(rowOf(a).getAttribute("data-selected")).toBeNull();
fireEvent.click(a);
expect(a.checked).toBe(true);
expect(rowOf(a).getAttribute("data-selected")).toBe("");
expect(((await box("Video B")) as HTMLInputElement).checked).toBe(false);
});
it("selects a range on Shift+click", async () => {
mount(three);
fireEvent.click(await box("Video A"));
fireEvent.click(await box("Video C"), { shiftKey: true });
expect(((await box("Video B")) as HTMLInputElement).checked).toBe(true);
expect(((await box("Video C")) as HTMLInputElement).checked).toBe(true);
});
it("has the header box say all, some or none, and cover the rows", async () => {
mount(three);
const all = (await header()) as HTMLInputElement;
expect([all.checked, all.indeterminate]).toEqual([false, false]);
fireEvent.click(await box("Video B"));
expect([all.checked, all.indeterminate]).toEqual([false, true]);
fireEvent.click(all);
expect([all.checked, all.indeterminate]).toEqual([true, false]);
expect(((await box("Video C")) as HTMLInputElement).checked).toBe(true);
fireEvent.click(all);
expect([all.checked, all.indeterminate]).toEqual([false, false]);
expect(((await box("Video C")) as HTMLInputElement).checked).toBe(false);
});
it("toggles the walked row on x and clears on Esc", async () => {
mount(three);
const b = (await box("Video B")) as HTMLInputElement;
press("j");
press("j");
await act(async () => press("x"));
expect(b.checked).toBe(true);
await act(async () => press("Escape"));
expect(b.checked).toBe(false);
});
it("clears the selection on a switch to another list holding the same video", async () => {
const { rerender } = mount({ 1: [vid("A", 0)], 2: [vid("A", 0)] });
fireEvent.click(await box("Video A"));
h.selectedId = 2;
rerender();
await waitFor(() => expect(api.playlist).toHaveBeenCalledWith(2));
await waitFor(() => expect(screen.queryByText("List 1")).toBeNull());
expect(((await box("Video A")) as HTMLInputElement).checked).toBe(false);
});
it("drops a row removed from the list out of the selection", async () => {
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
mount(three);
fireEvent.click(await box("Video A"));
fireEvent.click(await box("Video B"));
await rowMenu("Video A", "playlists.removeItem");
await waitFor(() =>
expect(screen.queryByRole("checkbox", { name: "playlists.selectRow:Video A" })).toBeNull(),
);
const all = (await header()) as HTMLInputElement;
expect(all.indeterminate).toBe(true);
expect(((await box("Video B")) as HTMLInputElement).checked).toBe(true);
});
it("offers no selecting on Watch later", async () => {
h.selectedId = WATCH_LATER;
mount({ [WATCH_LATER]: [vid("A", 0)] });
await screen.findByRole("button", { name: "card.play — Video A" });
expect(screen.queryAllByRole("checkbox", { name: /playlists\.select/ })).toEqual([]);
press("j");
press("x");
expect(screen.queryAllByRole("checkbox", { name: /playlists\.select/ })).toEqual([]);
});
});
+39 -4
View File
@@ -41,6 +41,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { playlistName, playlistNameText } from "../../lib/playlistName";
import { reinsertAt } from "../../lib/reinsert";
import { useRowSelection } from "../../lib/useRowSelection";
import { useUndoable } from "../../lib/useUndoable";
import { cycleSort, type SortState } from "../../lib/columnSort";
import { useUrlMirror } from "../../lib/useUrlMirror";
@@ -79,7 +80,7 @@ import {
type Column,
type FilterMap,
} from "../../lib/tableColumns";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
import { GRIP_KEY, playlistColumns, selectColumn } from "./playlistColumns";
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
import { DragHandle } from "../../components/ui/DragHandle";
import { useOrderItems } from "../../components/ui/useOrderItems";
@@ -119,6 +120,7 @@ function Row({
video,
index,
columns,
selected,
dragDisabled,
dragHint,
menuProps,
@@ -127,6 +129,7 @@ function Row({
/** Its position among the rows shown — the row is a keyboard item (lib/listNav). */
index: number;
columns: Column<Video>[];
selected: boolean;
dragDisabled: boolean;
dragHint: string;
/** useContextMenu's trigger props. On the WHOLE row, not a handle: a right-click anywhere in the
@@ -152,7 +155,8 @@ function Row({
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
// the same lag by another route (150ms behind the pointer, on every row).
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
className="group border-b border-border/50 state-layer transition-colors"
className={`group border-b border-border/50 transition-colors ${selected ? "bg-accent/12 hover:bg-accent/16" : "state-layer"}`}
data-selected={selected ? "" : undefined}
{...navItemAttrs(index)}
{...menuProps}
>
@@ -445,6 +449,25 @@ function PlaylistDetail() {
[items, columns, filters, viewSort, groupBy, lang],
);
// The rows the bulk actions cover. Watch later offers no edit, so it has nothing to select them for.
const selection = useRowSelection(
items.map((v) => v.id),
shown.map((v) => v.id),
selectedId,
);
const tableColumns = canEdit
? [
selectColumn({
t,
state: selection.headerState,
onToggleAll: selection.toggleAllShown,
isSelected: selection.isSelected,
onToggle: selection.toggle,
}),
...columns,
]
: columns;
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
// offers itself when the view is a pure reordering of the whole list.
@@ -1106,7 +1129,7 @@ function PlaylistDetail() {
min-content, and a nowrap title cell's min-content is the entire title. */}
<table className="w-full table-fixed border-collapse text-sm">
<ColumnHead
columns={columns}
columns={tableColumns}
sort={viewSort}
onToggleSort={toggleSort}
filters={filters}
@@ -1130,13 +1153,25 @@ function PlaylistDetail() {
}
: undefined
}
selection={
canEdit
? {
toggle: (i) => {
const v = shownRef.current[i];
if (v) selection.toggle(v.id);
},
clear: selection.clear,
}
: undefined
}
>
{shown.map((v, i) => (
<Row
key={v.id}
index={i}
video={v}
columns={columns}
columns={tableColumns}
selected={selection.isSelected(v.id)}
dragDisabled={!canEdit || viewActive}
dragHint={dragHint}
menuProps={rowMenu.triggerProps(v)}
@@ -6,6 +6,7 @@ import { formatDuration } from "../../lib/format";
import type { Column } from "../../lib/tableColumns";
import { Pressable } from "../../components/ui/Pressable";
import RemoteImage from "../../components/ui/RemoteImage";
import { RowSelectBox } from "../../components/ui/RowSelectBox";
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
//
@@ -145,6 +146,46 @@ export function playlistColumns({
return cols;
}
/** The box column, first in the row, for a list whose rows can be selected. A separate column from
* `playlistColumns`, whose array the view's filter and sort read: a tick must not re-run them. */
export function selectColumn({
t,
state,
onToggleAll,
isSelected,
onToggle,
}: {
t: TFunction;
state: "none" | "some" | "all";
onToggleAll: () => void;
isSelected: (id: string) => boolean;
onToggle: (id: string, range: boolean) => void;
}): Column<Video> {
const allLabel = t("playlists.selectAllShown");
return {
key: "select",
header: allLabel,
headerCell: (
<RowSelectBox
label={allLabel}
title={allLabel}
checked={state === "all"}
mixed={state === "some"}
onToggle={onToggleAll}
/>
),
width: "2.25rem",
hideInCard: true,
render: (v) => (
<RowSelectBox
label={t("playlists.selectRow", { title: v.title })}
checked={isSelected(v.id)}
onToggle={(shift) => onToggle(v.id, shift)}
/>
),
};
}
// A video's length, or its live / upcoming state when it has none. `onPicture`: the dark badge on the
// thumbnail (the narrow row), readable on any frame, like the media card's length badge.
function lengthCell(v: Video, t: TFunction, onPicture = false): ReactNode {