feat(design): the channel menu's Tags and Priority submenus (C1, E28 S3e3b)

Tags A-Z as in-place checks (the menu stays open for the next); Priority
as the fixed steps 10 5 3 2 1 0, an off-scale current value ticked in its
place. A pick re-sorts the list at once, moving only that row
(placeByPriority, the server's order), and the table / card pager and
the focus follow it (DataTable and useCardPager take a reveal seq,
listNav.focusNavItem lands on the row's own "…", a virtualized card
revealed first). ActionMenu rows take check: many | one. The menu reads
the row fresh from the cache and keys its target by id.
This commit is contained in:
2026-09-26 17:34:47 +02:00
parent 2b3094ffc5
commit 5e5c8a42a9
15 changed files with 467 additions and 28 deletions
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { placeByPriority, priorityChoices } from "./channelOrder";
const row = (id: string, priority: number, title: string | null = id) => ({ id, priority, title });
const ids = (rows: { id: string }[]) => rows.map((r) => r.id);
describe("placeByPriority", () => {
const list = [row("a", 10), row("b", 5), row("c", 5), row("d", 0), row("e", 0)];
it("bubbles a raised channel up to the first row it now outranks", () => {
expect(ids(placeByPriority([...list.slice(0, 4), { ...list[4]!, priority: 5 }], "e"))).toEqual([
"a",
"b",
"c",
"e",
"d",
]);
});
it("sinks a lowered channel below every row it no longer outranks", () => {
expect(ids(placeByPriority([{ ...list[0]!, priority: 0 }, ...list.slice(1)], "a"))).toEqual([
"b",
"c",
"a",
"d",
"e",
]);
});
it("orders equal priorities by title, case-folded, a missing title last", () => {
const rows = [row("x", 3, "beta"), row("y", 3, null), row("z", 3, "Alpha")];
expect(ids(placeByPriority(rows, "z"))).toEqual(["z", "x", "y"]);
expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y"))).toEqual(["x", "y"]);
});
it("leaves every other row where it was, even ones out of the server's order", () => {
const rows = [row("p", 0), row("q", 9), row("r", 2)];
// r lands before the first row it outranks; p and q keep their (unsorted) order.
expect(ids(placeByPriority(rows, "r"))).toEqual(["r", "p", "q"]);
});
it("returns an unknown id's list unchanged", () => {
expect(ids(placeByPriority(list, "nope"))).toEqual(ids(list));
});
});
describe("priorityChoices", () => {
it("offers the fixed steps, and an off-scale current value in its place", () => {
expect(priorityChoices(3)).toEqual([10, 5, 3, 2, 1, 0]);
expect(priorityChoices(7)).toEqual([10, 7, 5, 3, 2, 1, 0]);
expect(priorityChoices(-2)).toEqual([10, 5, 3, 2, 1, 0, -2]);
});
});
+41
View File
@@ -0,0 +1,41 @@
// The channel manager's list order, and the priority steps its menu offers (E28 S3e3b).
/** The steps a channel's menu offers (owner, 2026-09-26): fixed levels, so bubbling a channel up
* the list is one pick, not a run of +1 presses, and not straight to the top either. The row's
* stepper still sets any value, negatives included. */
const PRIORITY_STEPS: readonly number[] = [10, 5, 3, 2, 1, 0];
/** The steps to offer for a channel at `current`: the fixed ones, plus `current` itself in its
* place when it is off the scale (a 7, a -2) — the menu always shows, ticked, where it is now. */
export function priorityChoices(current: number): number[] {
return PRIORITY_STEPS.includes(current)
? [...PRIORITY_STEPS]
: [...PRIORITY_STEPS, current].sort((a, b) => b - a);
}
interface Ordered {
id: string;
priority: number;
title?: string | null;
}
/** The server's order (`routes/channels.py`: priority desc, then the title case-folded, a missing
* title last). */
function before(a: Ordered, b: Ordered): boolean {
if (a.priority !== b.priority) return a.priority > b.priority;
if (a.title == null) return false;
if (b.title == null) return true;
return a.title.toLowerCase() < b.title.toLowerCase();
}
/** `rows` with the channel `id` taken out and put back where its priority now places it, every
* other row left exactly where it was — the server's order, without a refetch that would reorder
* anything the reader did not touch. */
export function placeByPriority<T extends Ordered>(rows: readonly T[], id: string): T[] {
const moved = rows.find((r) => r.id === id);
if (!moved) return [...rows];
const rest = rows.filter((r) => r !== moved);
const at = rest.findIndex((r) => before(moved, r));
rest.splice(at < 0 ? rest.length : at, 0, moved);
return rest;
}
+22 -1
View File
@@ -6,7 +6,7 @@
import { useRef, useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { navItemAttrs, useListNav } from "./listNav";
import { focusNavItem, navItemAttrs, useListNav } from "./listNav";
import { pushLayer, removeLayer } from "./layerStack";
afterEach(cleanup);
@@ -615,3 +615,24 @@ describe("useListNav · Delete (E28 S3e2: a playlist row's X became the Delete k
expect(gone("d0")).toBe(false);
});
});
describe("focusNavItem", () => {
it("hands the focus to the control `pick` finds in the item, else to the item", () => {
Element.prototype.scrollIntoView ??= () => {};
const { container } = render(<List id="a" count={3} />);
act(() => focusNavItem(container, 2, (item) => item.querySelector<HTMLElement>("button")));
expect(focused()).toBe("a2-action");
act(() => focusNavItem(container, 1));
expect(focused()).toBe("a1");
});
it("brings a virtualized item in first, then focuses its control", async () => {
Element.prototype.scrollIntoView ??= () => {};
const reveal = vi.fn();
const { container, rerender } = render(<List id="a" count={5} mounted={2} reveal={reveal} />);
act(() => focusNavItem(container, 4, (item) => item.querySelector<HTMLElement>("button")));
expect(reveal).toHaveBeenCalledWith(4);
rerender(<List id="a" count={5} reveal={reveal} />);
await waitFor(() => expect(focused()).toBe("a4-action"));
});
});
+27 -3
View File
@@ -213,20 +213,44 @@ function focusItem(el: HTMLElement, reveal = true): void {
if (reveal) el.scrollIntoView?.({ block: "nearest" });
}
function focusIndex(list: NavList, index: number): void {
function focusIndex(
list: NavList,
index: number,
land: (el: HTMLElement) => void = (el) => focusItem(el),
): void {
const el = itemAt(list, index);
if (el) return focusItem(el);
if (el) return land(el);
// Virtualized and not mounted: have the list scroll it in, then take it as soon as it renders.
list.reveal(index);
let frames = 0;
const wait = () => {
const got = itemAt(list, index);
if (got) focusItem(got);
if (got) land(got);
else if (++frames < REVEAL_FRAMES) requestAnimationFrame(wait);
};
requestAnimationFrame(wait);
}
/** Hand the focus to item `index` of the list registered inside `within` — or, with `pick`, to the
* control `pick` finds in it (the row's own "…") — bringing a virtualized item into the DOM first
* and scrolling it just far enough to be seen. For a caller that MOVED an item and follows it: a
* channel re-sorted by the priority its menu set, whose node may have been re-inserted (the focus
* then fell to <body>) or scrolled off. */
export function focusNavItem(
within: HTMLElement,
index: number,
pick?: (item: HTMLElement) => HTMLElement | null,
): void {
const list = [...lists.values()].find((l) => within.contains(l.el));
if (!list) return;
focusIndex(list, index, (item) => {
const to = pick?.(item);
if (!to) return focusItem(item);
to.focus({ preventScroll: true });
item.scrollIntoView?.({ block: "nearest" });
});
}
function step(delta: 1 | -1): void {
const cur = current();
if (!cur) {
@@ -84,3 +84,27 @@ describe("useCardPager — an out-of-range page", () => {
expect(result.current.paged).toEqual(rows(30)); // and the rows are never sliced in table mode
});
});
describe("useCardPager — following a moved row", () => {
it("turns to the page that holds the row on each new seq, and not on mount", () => {
const { result, rerender } = renderHook(
({ list, seq }) =>
useCardPager(list, KEY, true, seq ? { seq, match: (r: number) => r === 3 } : null),
{ initialProps: { list: rows(30), seq: 0 } },
);
act(() => result.current.pagerProps.onSizeChange(10));
act(() => result.current.pagerProps.onPageChange(2));
// The row 3 sank to place 25: seq 1 follows it to the third page…
const sunk = [...rows(30).filter((r) => r !== 3)];
sunk.splice(25, 0, 3);
rerender({ list: sunk, seq: 1 });
expect(result.current.pagerProps.page).toBe(2);
// …and bubbled up to place 4: seq 2 follows it back to the first.
rerender({ list: rows(30), seq: 2 });
expect(result.current.pagerProps.page).toBe(0);
// A re-render with the same seq leaves the reader's own paging alone.
act(() => result.current.pagerProps.onPageChange(1));
rerender({ list: rows(30), seq: 2 });
expect(result.current.pagerProps.page).toBe(1);
});
});
+16 -1
View File
@@ -10,11 +10,26 @@ import { readAccount, writeAccount } from "./storage";
//
// The page size persists per account; the page itself is transient. `enabled` is the card layout
// flag — in table mode DataTable does its own paging, so this does no work and holds still.
export function useCardPager<T>(rows: T[], sizeKey: string, enabled: boolean) {
export function useCardPager<T>(
rows: T[],
sizeKey: string,
enabled: boolean,
/** Follow a row the caller just moved: each new `seq` turns to the page holding the row `match`
* finds — the same contract as `DataTable`'s `reveal`, for the card paint of the same list. */
reveal?: { seq: number; match: (row: T) => boolean } | null,
) {
const { t } = useTranslation();
const [size, setSize] = useState(() => parseCardSize(readAccount(sizeKey, null)));
const [page, setPage] = useState(0);
// During render against the last seq seen, as in DataTable (seeded so a mount never jumps).
const [revealSeen, setRevealSeen] = useState(reveal?.seq);
if (reveal && reveal.seq !== revealSeen) {
setRevealSeen(reveal.seq);
const at = rows.findIndex(reveal.match);
if (enabled && at >= 0 && size > 0) setPage(Math.floor(at / size));
}
const totalPages = size <= 0 ? 1 : Math.max(1, Math.ceil(rows.length / size));
const safePage = Math.min(page, totalPages - 1);