Files
siftlode/frontend/src/components/ui/ActionMenu.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

270 lines
11 KiB
TypeScript

import { Fragment, useState, type ReactNode } from "react";
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 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`) — all but a `check: { kind: "many" }` row, which toggles in place; a
// `disabled` item (e.g. a mutation already in flight) is inert.
//
// The ITEM LIST is split 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
// of the split: a context menu that drifts from the overflow menu beside it is two menus, and the
// user has to learn both.
/** Shared surface paint for every menu built on this file — the ⋯ menu, its submenus, and
* `ContextMenu`. One constant so a context menu can never look like a different component. */
export const MENU_SURFACE = `${SURFACE.menu} min-w-52 animate-[popIn_0.16s_ease]`;
// No `disabled:opacity-50` on the row itself: a disabled row may carry a `note`, and opacity on the
// row would dim the one line that explains it. The icon and the label dim instead (`dim`).
const ROW =
"flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition";
const dim = (disabled: boolean | undefined) => (disabled ? " opacity-50" : "");
/** The label, and under it the row's note: why it is off, or the state of what it opens. */
function RowLabel({ item }: { item: MenuItemBase }) {
return (
<span className="min-w-0 flex-1">
<span className={`block truncate${dim(item.disabled)}`}>{item.label}</span>
{item.note && <span className="block text-2xs leading-snug text-muted">{item.note}</span>}
</span>
);
}
interface MenuItemBase {
id: string;
label: string;
icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending)
disabled?: boolean;
/** Optional native tooltip (carries a longer hint the label alone can't) — for an ENABLED row. */
title?: string;
/** Why a DISABLED row is off, shown as a line under its label. Not a `title`: a disabled button
* fires no pointer events (no tooltip) and roving focus skips it, so a reason there reached
* nobody. An `aria-disabled` row was tried and reverted (floating-ui skips
* it identically, and the mouse got a highlight that then no-opped). Visible text is the one
* place a disabled row's reason can be: seen, and read in a screen reader's browse mode.
* On an ENABLED row it is the state of what the row opens ("Downloaded" under "Download…"),
* for the same reason: a `title` shows to a resting mouse only. */
note?: string;
/** Draw a divider above this item. Ignored on the first item — a menu never opens with a rule. */
separatorBefore?: boolean;
}
interface ActionItem extends MenuItemBase {
onSelect: () => void;
/** Destructive action: painted in the danger colour, like the outline-danger buttons. */
danger?: boolean;
/** Run INSIDE the click, then close — for a clipboard write, which Safari allows only within the
* 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;
}
interface SubmenuItem extends MenuItemBase {
/** Non-empty → this row opens a submenu instead of firing. */
items: readonly ActionMenuItem[];
onSelect?: never;
}
// A discriminated union rather than "onSelect and items are both optional": the invariant is
// "a row either fires or it opens a submenu, never both and never neither", and this codebase's
// house rule is that such a rule lives in the type, not in a comment above it.
export type ActionMenuItem = ActionItem | SubmenuItem;
const isSubmenu = (it: ActionMenuItem): it is SubmenuItem => Array.isArray(it.items);
/** The rows of a menu: leaf actions, submenus and separators, from one item array. Rendered inside a
* `PopoverSurface` (which supplies the list registry) — see `ActionMenu` and `ContextMenu`.
* `onClose` closes the ROOT menu, so selecting deep inside a submenu dismisses the whole stack. */
export function MenuItemList({
items,
onClose,
}: {
items: readonly ActionMenuItem[];
onClose: () => void;
}) {
return (
<>
{items.map((it, i) => (
<Fragment key={it.id}>
{it.separatorBefore && i > 0 && (
<div role="separator" className="my-1 h-px bg-border" aria-hidden="true" />
)}
{isSubmenu(it) ? (
<SubMenu item={it} onClose={onClose} />
) : (
<PopoverItem
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.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)}`}
>
{it.icon}
</span>
)}
<RowLabel item={it} />
</PopoverItem>
)}
</Fragment>
))}
</>
);
}
/** One row that opens a nested menu. It is a real `PopoverItem`, so the parent's roving focus and
* typeahead reach it; the nested popover anchors to that same button.
*
* Why it nests correctly with no extra wiring: this component renders INSIDE the parent's
* `PopoverSurface`, i.e. inside its `<FloatingNode>`, so `useFloatingNodeId()` picks the parent up
* as its tree parent. That is what makes Floating UI's outside-press tree-aware — a press inside
* the submenu is not "outside" the parent, and Escape closes the topmost layer only. */
function SubMenu({ item, onClose }: { item: SubmenuItem; onClose: () => void }) {
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
placement: "right-start",
offset: 2,
// A mouse expects the submenu to fall open under the pointer; a finger has no hover and taps.
hoverAlso: true,
// Target lists can be long (every playlist you own), so shrink to the viewport instead of
// running off the bottom at 768px.
fitHeight: true,
});
const { setReference } = pop.refs;
return (
<>
<PopoverItem
elementRef={setReference}
{...pop.getReferenceProps()}
role="menuitem"
disabled={item.disabled}
label={item.label}
title={item.title}
aria-haspopup="menu"
aria-expanded={open}
onKeyDown={(e) => {
// The native-menu keys. Enter/Space already open it via useClick; ArrowRight is the one
// the click handler doesn't cover.
if (e.key === "ArrowRight") {
e.preventDefault();
setOpen(true);
}
}}
className={ROW}
>
{item.icon && (
<span className={`shrink-0 text-muted${dim(item.disabled)}`}>{item.icon}</span>
)}
<RowLabel item={item} />
<ChevronRight className="h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
</PopoverItem>
<PopoverSurface
popover={pop}
ariaLabel={item.label}
className={`${MENU_SURFACE} overflow-y-auto`}
style={{ maxHeight: "min(24rem, var(--fit-max-h))" }}
>
{/* ArrowLeft steps back out to the parent row — Escape would close the whole stack, and a
submenu you can only leave by dismissing everything is not a submenu. Focus returns to
the trigger via the surface's own returnFocus. */}
<div
onKeyDown={(e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
e.stopPropagation();
setOpen(false);
}
}}
>
<MenuItemList items={item.items} onClose={onClose} />
</div>
</PopoverSurface>
</>
);
}
export default function ActionMenu({
items,
ariaLabel,
className,
trigger,
}: {
items: readonly ActionMenuItem[];
/** Accessible name for the trigger (e.g. "Catalog actions"). */
ariaLabel: string;
/** Trigger className override; omit for the default glass ⋯ icon button. */
className?: string;
/** Custom trigger content; omit for the default ⋯ icon. */
trigger?: ReactNode;
}) {
const [open, setOpen] = useState(false);
// A disabled item (a mutation already in flight) is skipped by roving keyboard nav automatically:
// PopoverItem renders it as a native `disabled` button, which useListNavigation reads and never
// focuses. No explicit disabledIndices needed. See ActionMenu.dom.test.tsx for the guard.
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
const { setReference } = pop.refs;
return (
<>
{/* A caller-shaped trigger (a labelled pill) is a surface; the default "…" is a glyph. */}
{className ? (
<Pressable
ref={setReference}
{...pop.getReferenceProps()}
aria-label={ariaLabel}
title={ariaLabel}
className={className}
>
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
</Pressable>
) : (
<IconButton
variant="glass"
size="md"
ref={setReference}
{...pop.getReferenceProps()}
label={ariaLabel}
>
{trigger ?? <MoreHorizontal aria-hidden="true" />}
</IconButton>
)}
<PopoverSurface popover={pop} ariaLabel={ariaLabel} className={MENU_SURFACE}>
<MenuItemList items={items} onClose={() => setOpen(false)} />
</PopoverSurface>
</>
);
}