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
+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)}