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
+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,
};
}