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