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
+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)}`}
>