Files
siftlode/frontend/src/lib/useRowSelection.ts
T
peter efbbb23525 feat(playlists): act on the selected rows from the toolbar, and undo either
While rows are selected the toolbar's second row is the band: the count,
the last target as one click, Move to, Remove, Clear selection. Both write
in one request and undo back to the places; a video the target held before
stays there. Delete takes the selection when there is one. A failure puts
the rows back ticked.

A row's own move goes through the same write, so it undoes too, and the
single-row move route and service function are gone. A playlist row drops
its grip below a 24rem table: the row menu keeps the moves.
2026-11-01 22:07:09 +01:00

83 lines
2.8 KiB
TypeScript

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;
}
/** Tick these again: an action that took them off and failed. */
function select(ids: readonly string[]) {
setSelected((now) => new Set([...now, ...ids]));
}
return {
shownSelected,
hiddenCount: selected.size - shownSelected.length,
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,
select,
};
}