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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user