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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user