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
@@ -84,6 +84,37 @@ describe("ActionMenu", () => {
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
});
it("a 'many' check row toggles in place: the menu stays open and the focus stays on it", async () => {
const onSelect = vi.fn();
renderMenu([
{ id: "rust", label: "rust", check: { kind: "many", checked: true }, onSelect },
{ id: "go", label: "go", check: { kind: "many", checked: false }, onSelect: vi.fn() },
]);
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
const rows = screen.getAllByRole("menuitemcheckbox", { hidden: true });
expect(rows.map((r) => r.getAttribute("aria-checked"))).toEqual(["true", "false"]);
fireEvent.click(rows[0]!);
// Synchronously — no close, no deferred act — and nothing closed around it.
expect(onSelect).toHaveBeenCalledTimes(1);
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true })).toHaveLength(2);
await new Promise((r) => setTimeout(r, 0));
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true })).toHaveLength(2);
});
it("a 'one' check row is a radio that picks and closes like any row", async () => {
const onSelect = vi.fn();
renderMenu([
{ id: "10", label: "10", check: { kind: "one", checked: false }, onSelect },
{ id: "5", label: "5", check: { kind: "one", checked: true }, onSelect: vi.fn() },
]);
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
const rows = screen.getAllByRole("menuitemradio", { hidden: true });
expect(rows.map((r) => r.getAttribute("aria-checked"))).toEqual(["false", "true"]);
fireEvent.click(rows[0]!);
expect(screen.queryByRole("menuitemradio", { hidden: true })).toBeNull();
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
});
it("a disabled item is inert — rendered disabled and never fires onSelect", () => {
const onSelect = vi.fn();
renderMenu([{ id: "sync", label: "Sync", onSelect, disabled: true }]);
+20 -3
View File
@@ -3,13 +3,15 @@ import { ChevronRight, MoreHorizontal } from "lucide-react";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks";
import { IconButton } from "./IconButton";
import { MenuCheck } from "./MenuCheck";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert.
// fire-and-close (`onSelect`) — all but a `check: { kind: "many" }` row, which toggles in place; a
// `disabled` item (e.g. a mutation already in flight) is inert.
//
// E10 S2 split the ITEM LIST out of the trigger (`MenuItemList`) so the ⋯ menu and the right-click
// context menu (`ContextMenu`) render the same rows from the same item type. That is the whole point
@@ -62,6 +64,11 @@ interface ActionItem extends MenuItemBase {
* user's gesture (a task later, as `closeThenAct` runs every other row, it is refused). Only for
* an action that opens nothing and moves no focus: that is what the deferral exists for. */
inGesture?: boolean;
/** A checkable row, its state shown in the icon slot. `many`: one of a set that can all be on (a
* channel's tags) — a menuitemcheckbox that toggles IN PLACE: the menu stays open, so the next
* tag is one click away and the check just set is seen. `one`: one choice of several (a priority
* step) — a menuitemradio that picks and closes, like any other row. */
check?: { kind: "many" | "one"; checked: boolean };
items?: never;
}
@@ -99,19 +106,29 @@ export function MenuItemList({
<SubMenu item={it} onClose={onClose} />
) : (
<PopoverItem
role="menuitem"
role={
it.check
? it.check.kind === "many"
? "menuitemcheckbox"
: "menuitemradio"
: "menuitem"
}
checked={it.check?.checked}
disabled={it.disabled}
label={it.label}
title={it.title}
// Any row may open a dialog (a confirm), so every row closes first — see closeThenAct.
// A `many` row opens nothing and stays: the focus is still on it, nothing to hand back.
onSelect={() => {
if (it.check?.kind === "many") return it.onSelect();
if (!it.inGesture) return closeThenAct(onClose, it.onSelect);
it.onSelect();
onClose();
}}
className={`${ROW}${it.danger ? " text-danger" : ""}`}
>
{it.icon && (
{it.check && <MenuCheck kind={it.check.kind} on={it.check.checked} />}
{!it.check && it.icon && (
<span
className={`shrink-0 ${it.danger ? "text-danger" : "text-muted"}${dim(it.disabled)}`}
>
@@ -136,3 +136,27 @@ describe("DataTable · keyboardNav", () => {
expect(document.activeElement).toBe(document.body);
});
});
describe("DataTable · reveal", () => {
const many: Row[] = Array.from({ length: 25 }, (_, i) => ({ id: `r${i}` }));
const cols: Column<Row>[] = [
{ key: "name", header: "Name", cardPrimary: true, render: (r) => r.id },
];
const shown = () => [...document.querySelectorAll("tbody tr")].map((tr) => tr.textContent);
it("turns to the page that now holds a moved row on each new seq", () => {
viewport(true);
const view = (list: Row[], reveal: { key: string; seq: number } | null) => (
<DataTable rows={list} columns={cols} rowKey={(r) => r.id} reveal={reveal} />
);
const { rerender } = render(view(many, { key: "r22", seq: 0 }));
// The mount's seq is not a request: the first page stays.
expect(shown()[0]).toBe("r0");
rerender(view(many, { key: "r22", seq: 1 }));
expect(shown()).toContain("r22");
expect(shown()[0]).toBe("r20");
// r22 bubbled up to the top: the next seq follows it back to page one.
rerender(view([many[22]!, ...many.filter((r) => r.id !== "r22")], { key: "r22", seq: 2 }));
expect(shown()[0]).toBe("r22");
});
});
+14
View File
@@ -69,6 +69,7 @@ export default function DataTable<T>({
onFiltersChange,
fixedLayout = false,
keyboardNav = false,
reveal,
}: {
rows: T[];
columns: Column<T>[];
@@ -121,6 +122,10 @@ export default function DataTable<T>({
// Enter clicks the element a column's render marks `data-nav-open` (the channel name). Both row
// shapes register; listNav walks only the one on screen, so the hidden copy is never a stop.
keyboardNav?: boolean;
// Follow a row the caller just MOVED (a channel re-sorted by the priority its menu set): each new
// `seq` turns to the page that now holds the row `key`. Focus is the caller's (it knows which of
// the row's controls it came from); the page is the table's, so it is decided here.
reveal?: { key: string; seq: number } | null;
}) {
const { t } = useTranslation();
const initial = loadPersist(persistKey);
@@ -208,6 +213,15 @@ export default function DataTable<T>({
const allRows = size <= 0;
const totalPages = allRows ? 1 : Math.max(1, Math.ceil(sorted.length / size));
const safePage = Math.min(page, totalPages - 1);
// Adjusted DURING render against the last seq seen (the house's key-change reset), so the frame
// that re-sorted the row already shows its new page. Seeded with the mount's seq: arriving with a
// stale reveal must not move a page nobody asked to move.
const [revealSeen, setRevealSeen] = useState(reveal?.seq);
if (reveal && reveal.seq !== revealSeen) {
setRevealSeen(reveal.seq);
const at = sorted.findIndex((row) => rowKey(row) === reveal.key);
if (at >= 0 && !allRows) setPage(Math.floor(at / size));
}
const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size);
function toggleSort(key: string) {