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:
@@ -93,6 +93,24 @@ describe("isTypingTarget", () => {
|
||||
it("is false on ordinary content", () => {
|
||||
expect(isTypingTarget(press({ key: "a" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("is false on a box, where no letter types: the page's keys go on working after a tick", () => {
|
||||
for (const type of ["checkbox", "radio"]) {
|
||||
const el = document.createElement("input");
|
||||
el.type = type;
|
||||
document.body.append(el);
|
||||
expect(isTypingTarget(press({ key: "a" }, el))).toBe(false);
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("stays true on a slider, whose arrows are its own", () => {
|
||||
const el = document.createElement("input");
|
||||
el.type = "range";
|
||||
document.body.append(el);
|
||||
expect(isTypingTarget(press({ key: "ArrowLeft" }, el))).toBe(true);
|
||||
el.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isActivationTarget", () => {
|
||||
@@ -101,6 +119,11 @@ describe("isActivationTarget", () => {
|
||||
document.body.append(btn);
|
||||
expect(isActivationTarget(press({ key: " " }, btn))).toBe(true);
|
||||
btn.remove();
|
||||
const box = document.createElement("input");
|
||||
box.type = "checkbox";
|
||||
document.body.append(box);
|
||||
expect(isActivationTarget(press({ key: " " }, box))).toBe(true);
|
||||
box.remove();
|
||||
expect(isActivationTarget(press({ key: " " }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ export type KeyScope =
|
||||
| "search"
|
||||
| "list"
|
||||
| "listDelete"
|
||||
| "listSelect"
|
||||
| "undo"
|
||||
| "contextMenu"
|
||||
| "ytPlayer"
|
||||
@@ -46,6 +47,7 @@ const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
|
||||
"search",
|
||||
"list",
|
||||
"listDelete",
|
||||
"listSelect",
|
||||
]);
|
||||
/** True for a scope that acts on the page beneath the overlays (see `KeyScope`). */
|
||||
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
|
||||
@@ -138,6 +140,15 @@ export const SHORTCUTS = [
|
||||
// Its own scope beside `list`: only a list that can take an item out (a playlist's rows) registers
|
||||
// it, so the sheet does not promise Delete on the feed or a Plex grid, where nothing answers it.
|
||||
{ id: "remove", scope: "listDelete", keys: ["Delete"], labelKey: "keys.listRemove" },
|
||||
// …and only a list whose rows can be selected registers these. Escape stays quiet in a field: the
|
||||
// field's own Escape comes first, and a selection is not what the reader is in while typing.
|
||||
{ id: "select", scope: "listSelect", keys: ["x"], labelKey: "keys.listSelect" },
|
||||
{
|
||||
id: "clearSelection",
|
||||
scope: "listSelect",
|
||||
keys: ["Escape"],
|
||||
labelKey: "keys.listClearSelection",
|
||||
},
|
||||
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
|
||||
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
|
||||
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
|
||||
@@ -264,15 +275,22 @@ export function isTypingTarget(e: KeyboardEvent): boolean {
|
||||
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
|
||||
if (!el) return false;
|
||||
const tag = (el.tagName || "").toLowerCase();
|
||||
// A box takes no letters, and a click on a table row's box leaves the focus on it: `j` went dead
|
||||
// there. A slider stays a field, as its arrows are its own.
|
||||
if (isBox(el)) return false;
|
||||
return tag === "input" || tag === "textarea" || tag === "select" || !!el.isContentEditable;
|
||||
}
|
||||
|
||||
function isBox(el: HTMLElement | null): boolean {
|
||||
return el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio");
|
||||
}
|
||||
|
||||
/** True while focus is on something Space/Enter activates natively. Both players defer Space to a
|
||||
* focused button ("Skip intro") rather than toggling playback; this is that shared rule. */
|
||||
export function isActivationTarget(e: KeyboardEvent): boolean {
|
||||
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
|
||||
const tag = (el?.tagName || "").toLowerCase();
|
||||
return tag === "button" || tag === "a";
|
||||
return tag === "button" || tag === "a" || isBox(el);
|
||||
}
|
||||
|
||||
/** One printable character that is neither a letter nor a digit (`[`, `]`, `,`). */
|
||||
@@ -462,7 +480,7 @@ export function useActiveScopes(): KeyScope[] {
|
||||
// registers `page` in its own effect, which React runs after every child's — so by mount order
|
||||
// the module jumps would head the sheet, above the list keys of the page you are looking at.
|
||||
const surfaces = live.filter((s) => !isPageLevelScope(s));
|
||||
const pageLevel = (["list", "listDelete", "search", "page"] as const).filter((s) =>
|
||||
const pageLevel = (["list", "listSelect", "listDelete", "search", "page"] as const).filter((s) =>
|
||||
live.includes(s),
|
||||
);
|
||||
return [...surfaces, ...pageLevel, "global"];
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// `x` selects the walked item and Esc clears, in a list that takes a selection (a playlist's rows).
|
||||
// Its own file: listNav keeps module state.
|
||||
import { useRef } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { navItemAttrs, useListNav, type NavSelection } from "./listNav";
|
||||
import { useActiveScopes } from "./keymap";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function Rows({ selection }: { selection?: NavSelection }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: true, selection });
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} {...navItemAttrs(i)} data-testid={`r${i}`}>
|
||||
<button data-testid={`r${i}-action`}>action</button>
|
||||
<input data-testid={`r${i}-input`} />
|
||||
<input type="checkbox" data-testid={`r${i}-box`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Scopes() {
|
||||
return <output data-testid="scopes">{useActiveScopes().join(" ")}</output>;
|
||||
}
|
||||
|
||||
function press(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
||||
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
|
||||
(document.activeElement ?? document.body).dispatchEvent(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
const selection = (cleared = true) => ({
|
||||
toggle: vi.fn<(index: number) => void>(),
|
||||
clear: vi.fn(() => cleared),
|
||||
});
|
||||
|
||||
describe("useListNav · selecting", () => {
|
||||
it("toggles the walked item on x", () => {
|
||||
const s = selection();
|
||||
render(<Rows selection={s} />);
|
||||
press("j");
|
||||
press("j");
|
||||
const e = press("x");
|
||||
expect(s.toggle).toHaveBeenCalledExactlyOnceWith(1);
|
||||
expect(e.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("toggles the item a control inside it holds the focus for", () => {
|
||||
const s = selection();
|
||||
render(<Rows selection={s} />);
|
||||
screen.getByTestId("r2-action").focus();
|
||||
press("x");
|
||||
expect(s.toggle).toHaveBeenCalledExactlyOnceWith(2);
|
||||
});
|
||||
|
||||
it("goes on answering with the focus on the row's box, where a click leaves it", () => {
|
||||
const s = selection();
|
||||
render(<Rows selection={s} />);
|
||||
screen.getByTestId("r1-box").focus();
|
||||
press("x");
|
||||
expect(s.toggle).toHaveBeenCalledExactlyOnceWith(1);
|
||||
press("j");
|
||||
expect(document.activeElement).toBe(screen.getByTestId("r2"));
|
||||
});
|
||||
|
||||
it("does nothing on x with no item under the keyboard", () => {
|
||||
const s = selection();
|
||||
render(<Rows selection={s} />);
|
||||
const e = press("x");
|
||||
expect(s.toggle).not.toHaveBeenCalled();
|
||||
expect(e.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("types an x in a field instead of selecting", () => {
|
||||
const s = selection();
|
||||
render(<Rows selection={s} />);
|
||||
screen.getByTestId("r0-input").focus();
|
||||
press("x");
|
||||
expect(s.toggle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not flip back and forth while x is held down", () => {
|
||||
const s = selection();
|
||||
render(<Rows selection={s} />);
|
||||
press("j");
|
||||
press("x");
|
||||
press("x", { repeat: true });
|
||||
expect(s.toggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears on Esc wherever the focus is, and spends the key only when it cleared", () => {
|
||||
const s = selection();
|
||||
render(<Rows selection={s} />);
|
||||
expect(press("Escape").defaultPrevented).toBe(true);
|
||||
expect(s.clear).toHaveBeenCalledTimes(1);
|
||||
cleanup();
|
||||
|
||||
const idle = selection(false);
|
||||
render(<Rows selection={idle} />);
|
||||
expect(press("Escape").defaultPrevented).toBe(false);
|
||||
expect(idle.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves both keys alone in a list that takes no selection", () => {
|
||||
render(<Rows />);
|
||||
press("j");
|
||||
expect(press("x").defaultPrevented).toBe(false);
|
||||
expect(press("Escape").defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("lists the keys only over a list that takes a selection and has items", async () => {
|
||||
render(
|
||||
<>
|
||||
<Scopes />
|
||||
<Rows selection={selection()} />
|
||||
</>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId("scopes").textContent).toContain("listSelect"));
|
||||
cleanup();
|
||||
render(
|
||||
<>
|
||||
<Scopes />
|
||||
<Rows />
|
||||
</>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId("scopes").textContent).toContain("list"));
|
||||
expect(screen.getByTestId("scopes").textContent).not.toContain("listSelect");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -24,6 +24,8 @@ type ColumnFilter<T> =
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
// A header that is a control of its own (the box that selects every row), in place of `header`.
|
||||
headerCell?: ReactNode;
|
||||
render: (row: T) => ReactNode;
|
||||
align?: "left" | "right" | "center";
|
||||
width?: string;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useRowSelection } from "./useRowSelection";
|
||||
|
||||
const ABCDE = ["a", "b", "c", "d", "e"];
|
||||
|
||||
function setup(shown = ABCDE, all = ABCDE, scope: unknown = 1) {
|
||||
return renderHook(
|
||||
(p: { all: string[]; shown: string[]; scope: unknown }) =>
|
||||
useRowSelection(p.all, p.shown, p.scope),
|
||||
{ initialProps: { all, shown, scope } },
|
||||
);
|
||||
}
|
||||
|
||||
describe("useRowSelection", () => {
|
||||
it("toggles one row at a time, and says the selected rows in the order shown", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.toggle("d"));
|
||||
act(() => result.current.toggle("b"));
|
||||
expect(result.current.shownSelected).toEqual(["b", "d"]);
|
||||
act(() => result.current.toggle("d"));
|
||||
expect(result.current.shownSelected).toEqual(["b"]);
|
||||
expect(result.current.isSelected("b")).toBe(true);
|
||||
expect(result.current.isSelected("d")).toBe(false);
|
||||
});
|
||||
|
||||
it("selects the range from the last row toggled, in either direction", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.toggle("b"));
|
||||
act(() => result.current.toggle("d", true));
|
||||
expect(result.current.shownSelected).toEqual(["b", "c", "d"]);
|
||||
act(() => {
|
||||
result.current.clear();
|
||||
});
|
||||
act(() => result.current.toggle("d"));
|
||||
act(() => result.current.toggle("b", true));
|
||||
expect(result.current.shownSelected).toEqual(["b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("takes a range's state from the row the range ends on: it can clear rows too", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.toggle("a"));
|
||||
act(() => result.current.toggle("e", true));
|
||||
expect(result.current.shownSelected).toEqual(ABCDE);
|
||||
// `e` is selected, so a range ending on it clears; the anchor moved to `e` with the last click.
|
||||
act(() => result.current.toggle("b", true));
|
||||
expect(result.current.shownSelected).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("leaves rows outside the range alone", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.toggle("a"));
|
||||
act(() => result.current.toggle("c"));
|
||||
act(() => result.current.toggle("e", true));
|
||||
expect(result.current.shownSelected).toEqual(["a", "c", "d", "e"]);
|
||||
});
|
||||
|
||||
it("follows the order on screen, not the stored one", () => {
|
||||
const { result } = setup(["e", "c", "a", "b", "d"]);
|
||||
act(() => result.current.toggle("c"));
|
||||
act(() => result.current.toggle("b", true));
|
||||
expect(result.current.shownSelected).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("makes a range with no row to start from a plain toggle", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.toggle("c", true));
|
||||
expect(result.current.shownSelected).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("makes a range whose first row a filter hid a plain toggle", () => {
|
||||
const { result, rerender } = setup();
|
||||
act(() => result.current.toggle("a"));
|
||||
rerender({ all: ABCDE, shown: ["c", "d", "e"], scope: 1 });
|
||||
act(() => result.current.toggle("e", true));
|
||||
expect(result.current.shownSelected).toEqual(["e"]);
|
||||
});
|
||||
|
||||
it("has the header box cover the rows shown, and keep what a filter hides", () => {
|
||||
const { result, rerender } = setup();
|
||||
expect(result.current.headerState).toBe("none");
|
||||
act(() => result.current.toggle("a"));
|
||||
expect(result.current.headerState).toBe("some");
|
||||
rerender({ all: ABCDE, shown: ["c", "d"], scope: 1 });
|
||||
// The one selected row is hidden: nothing SHOWN is selected, and acting counts what is shown.
|
||||
expect(result.current.headerState).toBe("none");
|
||||
expect(result.current.shownSelected).toEqual([]);
|
||||
act(() => result.current.toggleAllShown());
|
||||
expect(result.current.headerState).toBe("all");
|
||||
expect(result.current.shownSelected).toEqual(["c", "d"]);
|
||||
act(() => result.current.toggleAllShown());
|
||||
expect(result.current.headerState).toBe("none");
|
||||
rerender({ all: ABCDE, shown: ABCDE, scope: 1 });
|
||||
expect(result.current.shownSelected).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("selects every row shown from a partial selection", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.toggle("b"));
|
||||
act(() => result.current.toggleAllShown());
|
||||
expect(result.current.shownSelected).toEqual(ABCDE);
|
||||
});
|
||||
|
||||
it("has no header state to show over no rows", () => {
|
||||
const { result } = setup([], []);
|
||||
expect(result.current.headerState).toBe("none");
|
||||
act(() => result.current.toggleAllShown());
|
||||
expect(result.current.headerState).toBe("none");
|
||||
});
|
||||
|
||||
it("drops a row that left the list, so it does not come back selected", () => {
|
||||
const { result, rerender } = setup();
|
||||
act(() => result.current.toggle("b"));
|
||||
act(() => result.current.toggle("c"));
|
||||
rerender({ all: ["a", "c", "d", "e"], shown: ["a", "c", "d", "e"], scope: 1 });
|
||||
expect(result.current.shownSelected).toEqual(["c"]);
|
||||
rerender({ all: ABCDE, shown: ABCDE, scope: 1 });
|
||||
expect(result.current.shownSelected).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("clears on a change of scope, in the render that changes it", () => {
|
||||
const { result, rerender } = setup();
|
||||
act(() => result.current.toggle("a"));
|
||||
// The same videos in another list are not selected there.
|
||||
rerender({ all: ABCDE, shown: ABCDE, scope: 2 });
|
||||
expect(result.current.shownSelected).toEqual([]);
|
||||
act(() => result.current.toggle("c", true));
|
||||
expect(result.current.shownSelected).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("says whether clearing did anything", () => {
|
||||
const { result } = setup();
|
||||
let cleared = true;
|
||||
act(() => {
|
||||
cleared = result.current.clear();
|
||||
});
|
||||
expect(cleared).toBe(false);
|
||||
act(() => result.current.toggle("a"));
|
||||
act(() => {
|
||||
cleared = result.current.clear();
|
||||
});
|
||||
expect(cleared).toBe(true);
|
||||
expect(result.current.shownSelected).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts a selected row a filter hides as something to clear", () => {
|
||||
const { result, rerender } = setup();
|
||||
act(() => result.current.toggle("a"));
|
||||
rerender({ all: ABCDE, shown: ["c"], scope: 1 });
|
||||
let cleared = false;
|
||||
act(() => {
|
||||
cleared = result.current.clear();
|
||||
});
|
||||
expect(cleared).toBe(true);
|
||||
rerender({ all: ABCDE, shown: ABCDE, scope: 1 });
|
||||
expect(result.current.shownSelected).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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