Files
siftlode/frontend/src/components/ui/ActionMenu.tsx
T
peter bc138e3b37 fix(ui): a disabled menu row says why it is off, under its label
The reason for a disabled menu row lived in a `title` on a natively disabled
button: no pointer events, so no tooltip, and roving focus skips the row —
four translated sentences reached nobody (the Watch later rename/delete
lock, "no other list to move to", the reorder gate). An aria-disabled row was
tried and reverted in E10's review. ActionMenu items now take a `note`,
rendered as a muted line under a disabled row's label: seen, and readable in
a screen reader's browse mode. Only the icon and label dim — row-level
opacity would dim the explanation too. The reorder group states its reason
once, under its first row.
2026-09-19 07:57:20 +02:00

231 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Fragment, useState, type ReactNode } from "react";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks";
// 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.
//
// 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
// 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 = "glass-menu min-w-52 rounded-xl p-1.5 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 — on a disabled row only — the reason it is off. */
function RowLabel({ item }: { item: MenuItemBase }) {
return (
<span className="min-w-0 flex-1">
<span className={`block truncate${dim(item.disabled)}`}>{item.label}</span>
{item.disabled && 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 (E10 review rounds 1–2: 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. */
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;
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="menuitem"
disabled={it.disabled}
label={it.label}
title={it.title}
// Any row may open a dialog (a confirm), so every row closes first — see closeThenAct.
onSelect={() => closeThenAct(onClose, it.onSelect)}
className={`${ROW}${it.danger ? " text-danger" : ""}`}
>
{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,
});
return (
<>
<PopoverItem
elementRef={pop.refs.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" });
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={ariaLabel}
title={ariaLabel}
className={
className ??
"glass-card glass-hover flex items-center justify-center rounded-xl p-2 text-sm transition"
}
>
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
</button>
<PopoverSurface popover={pop} ariaLabel={ariaLabel} className={MENU_SURFACE}>
<MenuItemList items={items} onClose={() => setOpen(false)} />
</PopoverSurface>
</>
);
}