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
+34
View File
@@ -36,6 +36,15 @@ interface NavList {
readonly reveal: (index: number) => void;
/** Takes the item at `index` out of the list (Delete), or null for a list that cannot. */
readonly remove: () => ((index: number) => void) | null;
/** The list's selection (`x`, Esc), or null for a list whose items cannot be selected. */
readonly selection: () => NavSelection | null;
}
/** What `x` and Esc do in a list whose items can be selected. `clear` says whether it cleared
* anything: an Esc that did not is left for whoever else answers it. */
export interface NavSelection {
readonly toggle: (index: number) => void;
readonly clear: () => boolean;
}
const lists = new Map<HTMLElement, NavList>();
@@ -432,6 +441,27 @@ function onKey(e: KeyboardEvent): void {
},
{ inTopLayer },
);
dispatch(
e,
"listSelect",
{
// Anywhere in the item, unlike Delete: a tick is undone by the same key, and after a click on
// the box the focus sits inside the row. Held down, it must not flip back and forth.
select: (ev) => {
const cur = current();
const selection = cur?.list.selection();
if (!cur || !selection || ev.repeat) return;
ev.preventDefault();
selection.toggle(cur.index);
},
clearSelection: (ev) => {
let cleared = false;
for (const list of ordered()) if (list.selection()?.clear()) cleared = true;
if (cleared) ev.preventDefault();
},
},
{ inTopLayer },
);
}
// How long a removal gets to take its row out of the DOM before the focus gives up following it.
@@ -599,6 +629,8 @@ export function useListNav(
reveal?: (index: number) => void;
/** A list whose items the Delete key takes out (a playlist's rows): the item's index. */
onDelete?: (index: number) => void;
/** A list whose items can be selected, by `x` and Esc. */
selection?: NavSelection;
},
): void {
// Read when a key is pressed, so the registration below never re-runs for a new `opts` object.
@@ -609,6 +641,7 @@ export function useListNav(
const [hasItems, setHasItems] = useState(false);
useKeyScope("list", opts.enabled && hasItems);
useKeyScope("listDelete", opts.enabled && hasItems && !!opts.onDelete);
useKeyScope("listSelect", opts.enabled && hasItems && !!opts.selection);
useEffect(() => {
const el = ref.current;
if (!opts.enabled || !el) return;
@@ -618,6 +651,7 @@ export function useListNav(
cols: () => latestOpts().cols ?? measuredCols(el),
reveal: (i) => latestOpts().reveal?.(i),
remove: () => latestOpts().onDelete ?? null,
selection: () => latestOpts().selection ?? null,
});
el.setAttribute(LIST, "");
const check = () => setHasItems(el.querySelector(`[${INDEX}]`) !== null);